T084, 4.3: the last deferred reaches through declared seams; consumer_reach retired (plan 034) - #77
Conversation
…a collapsed DSN pair (plan 034) Realizes #1144 13.2 and 13.3, falsifier F13.1's `load_settings` block. - 13.2: `_refuse_non_postgresql_dsn` refuses either DSN (`OPENDOX_DATABASE_URL` or `OPENDOX_MIGRATION_DATABASE_URL`) whose URI scheme is not `postgresql://` or `postgres://`, naming the setting and the dialect kept. The keyword/value conninfo form (`host=h dbname=d …`) names no dialect at all and is unaffected — that syntax is libpq's own grammar, and no other driver reads it. - 13.3: `OPENDOX_MIGRATION_DATABASE_URL` stops being optional in `load_settings` (the `Setting` row's `required` flag, `_require` in place of `_optional`, and `RuntimeSettings.migration_database_url`'s type). A new `_refuse_the_same_dsn_in_both_settings` refuses the two DSNs being the exact same STRING, naming `OPENDOX_MIGRATION_DATABASE_URL`, once they are already known to agree on where they land (`_refuse_two_dsns_that_select_different_schemas`, unchanged, now called first): two DIFFERENT secrets for one role still pass, as the existing "single-role install" case documents. - Explicitly NOT in this task: 13.4-13.6 (`OPENDOX_INSTALL_MODE`, T070). Nothing here reads or names that setting, and `load_settings`'s only new required input is the migration DSN itself. Every existing call site that built an environment without `OPENDOX_MIGRATION_DATABASE_URL` needed one once it became required: `tests_runtime/conftest.py` gains a `migration_dsn` fixture (a `postgres_dsn` distinguished by a URI fragment, invisible to every DSN reader this module has); `test_api_endpoints.py`, `test_migrations_apply.py`, `test_runtime_cli.py` and `test_runtime_surface.py` thread it or a literal peer through. `test_two_dsns_that_select_different_schemas_are_refused`'s "a migration DSN that is simply absent" case is rewritten from accepted to refused, which is the behavior 13.3 changes. Two new tests (`test_a_non_postgresql_dsn_is_refused_naming_the_dialect_kept`, `test_the_same_dsn_in_both_settings_is_refused_naming_the_migration_one`) cover the two new refusals directly. Measured locally against this change (own Postgres container, bridge IP — this sandbox's host-mapped loopback ports are unreachable): `python -m pytest -q` reports 2469 passed, 11 skipped, 1 failed — the one failure is `tests/test_model_provider_broker.py::test_the_broker_child_inherits_no_ credential_shaped_environment`, already red against unmodified `main` (2d11641) in the same environment (an `LC_CTYPE` ambient in this sandbox, unrelated to runtime/config.py). Against `main`'s own reading (2479 selected / 2468 passed / 11 skipped, matching this repo's last recorded CI triple), this change is +2/+2/+0 for the two new tests — `validate.yml`'s `Pin the triple` floors (`MIN_SELECTED=2476`, `MIN_PASSED=2465`, `EXPECT_SKIPPED=11`) permit the rise unchanged. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…(Copilot review of this PR)
`urlsplit` itself raises for a DSN it cannot parse — MEASURED,
ValueError("Invalid IPv6 URL") for an unbracketed IPv6 host, which
tests_runtime/conftest.py's own postgres_dsn docstring names as "the
ordinary way to mis-set this variable". `_refuse_non_postgresql_dsn` called
`urlsplit(dsn).scheme` unguarded, so that ValueError escaped load_settings
as a bare exception instead of the promised ConfigurationError — the CLI's
boundary catches only ConfigurationError, so a malformed OPENDOX_DATABASE_URL
or OPENDOX_MIGRATION_DATABASE_URL would have printed a traceback instead of
a redacted refusal.
Wrapped the same way _split_url already wraps it for the broker settings
(Copilot review of openDox-code#25, round 24), with DSN-appropriate wording
rather than reused verbatim ("set it to the broker endpoint" does not fit
a database DSN). New test
test_an_unparseable_dsn_is_refused_and_never_raises_a_bare_valueerror
proves both DSNs are covered and that the value is never repeated in the
message.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… only for migrate Brett ruled on the held conflict (openxFactory#656, on the claim thread for plan 034's T071, 2026-09-28), choosing "Required only for migrate (Recommended)" over making the setting required everywhere: - OPENDOX_MIGRATION_DATABASE_URL goes back to OPTIONAL in `load_settings` (the `Setting` row's `required` flag, `RuntimeSettings.migration_ database_url`'s type back to `str | None`, `_optional` in place of `_require`). `load_migration_settings` is unaffected either way — it already independently required one, for `migrate`/`reset` alone. - Both refusals from the previous commits stay, and are now no-ops on an ABSENT migration DSN rather than being unreachable: `_refuse_non_ postgresql_dsn` and `_refuse_the_same_dsn_in_both_settings` each return early when the migration value is falsy, exactly the way `_refuse_two_ dsns_that_select_different_schemas` already treated "nothing to compare" as nothing to fault. When BOTH are given, every check still runs, in the same order as before (dialect, then schema-mismatch, then collapse). It is never defaulted from OPENDOX_DATABASE_URL. - This matches #1144 13.3's own text and `deploy/compose/docker-compose. yaml`'s separation (the `opendox` service never gets a migration DSN; `docs/runtime.md` § 3 never lists it as required) — neither file needed a change; both already said the now-ruled behavior. The plan's "stops being optional" line is a holder-side correction, not part of this PR, and #1144's own wording is unchanged. Reverted the 27-call-site ripple the `required` flip had forced, now that it is not needed: `tests_runtime/conftest.py`'s `migration_dsn` fixture is gone; `test_api_endpoints.py`, `test_migrations_apply.py`, `test_runtime_ cli.py` and `test_runtime_surface.py` are back to threading only the served DSN through every call site that does not itself test the migration path. All four files after conftest.py are byte-for-byte `main` again. `test_two_dsns_that_select_different_schemas_are_refused`'s "absent migration" case is back to ACCEPTED (with a note on why it was briefly the opposite), which is what the setting being optional again means for that test. Added three tests showing the ruled behavior, at the CLI dispatch level rather than only `load_settings` directly, next to the existing `migrate` counterpart: - `test_serve_and_status_load_with_no_migration_dsn_configured`: `status` reports no configuration refusal and `settings[…MIGRATION_DATABASE_URL] ` as `null` with only the served DSN set; `serve` starts (`ok: true`) the same way. - `test_the_collapse_is_refused_through_the_served_workload_too`: 13.3's collapse refusal still fires through `status`, not only through `load_settings` called directly, the moment both DSNs are given and are the same value. - `test_migrate_refuses_rather_than_borrowing_the_served_identity` (pre-existing, untouched) already covers "migrate refuses without it". Measured locally against this change (own Postgres container, bridge IP): `python -m pytest -q` reports 2472 passed, 11 skipped, 1 failed — the one failure is the same `tests/test_model_provider_broker.py:: test_the_broker_child_inherits_no_credential_shaped_environment` LC_CTYPE sandbox artifact already characterized as pre-existing and unrelated in the first commit on this branch. Against main's 2479 selected / 11 skipped in this same environment, this change is +5/+5/+0 (five tests: the three already on this branch plus the two new ones above) — `validate.yml`'s `Pin the triple` floors (`MIN_SELECTED=2476`, `MIN_PASSED=2465`, `EXPECT_SKIPPED=11`) permit the rise unchanged, and the exact skip count is unchanged. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ttings has Copilot review of this PR (thread on _refuse_non_postgresql_dsn's own definition): 13.2's dialect gate was wired into `load_settings` only. `load_migration_settings` — the loader `runtime migrate`/`reset` actually use — read OPENDOX_MIGRATION_DATABASE_URL, checked only that it was non-empty, and handed it straight to `Database`, so a non-PostgreSQL migration DSN (`sqlite:///x.db`, say) reached the driver instead of being refused by name at configuration. That is the same un-named failure 13.2 exists to prevent for the served loader, just reachable through the one path F13.1's falsifier does not call. One call to the existing `_refuse_non_postgresql_dsn`, right after the existing empty-DSN refusal and before `database_url`/`migration_database_ url` are both set to the same value. New test `test_migrate_refuses_a_non_postgresql_migration_dsn_at_configuration` is the dialect-refused twin of the existing `test_migrate_and_reset_need_ no_served_identity_and_no_broker`, which already shows an unreachable but valid-dialect migration DSN getting PAST configuration — this one shows a wrong-dialect one refused AT configuration, naming the setting and never repeating the DSN. Measured locally (own Postgres container, bridge IP): 2473 passed (+1), 11 skipped, 1 failed (the same pre-existing, unrelated LC_CTYPE sandbox artifact) — the new test is the only change to the count. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…n --local (plan 034) OPENDOX_INSTALL_MODE (`local` | `hosted`, default `hosted`) is read in runtime/config.py beside OPENDOX_OIDC_ISSUER and decides the install shape (#1144 13.4). `generate-and-open --local` makes the same selection (R1Q15 (b), as T007 batch H's 13.4 addendum reads); with neither the install is hosted (13.5). - A flag and a setting that disagree (`--local` beside OPENDOX_INSTALL_MODE=hosted) are refused, naming both. This is plan 034's fail-closed reading (Principle VII); no answer rules it and batch H does not write it into #1144. - LOCAL needs no broker: issuer, audience and key-set URL are empty. - LOCAL binds loopback only, with no opt-in. A non-loopback `--host` or OPENDOX_BIND_HOST is refused, naming the rule. The set is serve.py's own LOOPBACK_HOSTS, and a test holds the two equal. - HOSTED, set or by default, with no issuer refuses, naming OPENDOX_OIDC_ISSUER. generate-and-open asks the issuer first, so a run with nothing configured names it and `--local`. The hosted mode is otherwise unchanged (13.6). Holder readings on openxFactory#656 (Brett may overrule): - `runtime serve` refuses under local, because the API's identity is the broker's. - `runtime status` under local reports broker_keys "not configured (local mode)" and does not count it as a fault. - A broker setting beside local is refused by name. - An unrecognised mode value is refused, case-sensitively. The document server's generate-and-open resolves the shape before it scans, mints or binds anything. The hosted path loads the whole runtime configuration (R1Q16 (i); 13.4a). Also: - deploy/compose/.env.example gains OPENDOX_INSTALL_MODE=hosted, which test_every_runtime_setting_is_documented_in_env_example requires of every SETTINGS entry. - tests/test_doxbench_entrypoint.py's fixture now selects `--local` and scrubs the runtime settings, since the unset default is hosted and refuses with no issuer. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…hild (plan 034) A LOCAL install (T070's `generate-and-open --local`, or OPENDOX_INSTALL_MODE=local) now brings its own database (#1144 13.1, as T007 batch H's addendum reads; RULED R1Q16 (i)-(iv), 5850003126). - (i) `generate-and-open --local` starts a PostgreSQL server as its own direct child (subprocess.Popen, never pg_ctl) and reports it. The document server a user reaches is the process that owns it. - (ii) The server is started AND migrated: initdb once, an idempotent bootstrap (the database, the served role, and the compose stack's grants narrowed to this install's owner), then migrations.MigrationRunner as the owner, with the served role and database declared. - (iii) It ships as the `opendox[local]` extra: `opendox[runtime]` plus `pgserver>=0.1.4`, whose bundled binaries link only libc and libz. The `test` extra joins it, so F9.1's `.[test]` install still runs every case. - (iv) It stops with the entry point. SIGTERM is read as the Ctrl-C the serve loop already stops on, followed by a fast shutdown. PR_SET_PDEATHSIG is the backstop when the entry point is SIGKILLed. - Its data and socket directories live under OPENDOX_STATE_DIR, a new setting that defaults per user and must be absolute. The server listens on a 0700 Unix socket with listen_addresses empty: no TCP listener at all. - Both DSNs are supplied: two users over the one socket, which pass T071's three checks. An operator DSN beside `local` is refused by name, joining T070's broker settings (a holder reading on openxFactory#656). - `runtime status` reports database_bundle (data_dir, socket_dir, pid). `runtime migrate` under local migrates the bundle. THE MIGRATIONS GAP (assigned to T072 by the holder). pyproject maps migrations/*.sql into the wheel's data directory (share/opendox/migrations), without moving the root migrations/ that the image copies. An unset OPENDOX_MIGRATIONS_DIR is `migrations` wherever the working directory has one (today's default, unchanged), and otherwise the copy the installed distribution records. A test builds the wheel, installs it outside the checkout, runs from a directory with no migrations/, and migrates the bundled server. Also: - deploy/compose/.env.example gains OPENDOX_STATE_DIR=, because every SETTINGS entry is named there. - tests/test_doxbench_entrypoint.py stands the bundle in, since those cases test the model port. - T070's own tests stop passing DSNs beside `local`. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…xtra's setuptools The lock is extended under its own pins (`-c` this file), in a clean cpython 3.12.3 venv on linux x86_64, as its header asks. Five pins are new: - pgserver 0.1.4, with its own psutil, platformdirs and fasteners; - setuptools, for the wheel-install test's offline build. No earlier pin moved. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…it config (Copilot review) `tests/test_install_mode_entrypoint.py`'s `corpus` fixture ran `git commit` under the caller's global and system git configuration. A global `commit.gpgsign=true` therefore failed the setup before any install-mode probe ran. Measured with a hostile global config (`commit.gpgsign = true`, `gpg.program = /bin/false`): 7 errors at b50e3b1, 14 passed here. The fixture now sets GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1, as tests/test_checkout_head.py does. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ot be (Copilot review)
load_migration_settings recorded OPENDOX_INSTALL_MODE=local but never asked
refuse_what_a_local_install_cannot_be. So `runtime migrate` and a
confirmed `runtime reset` accepted OPENDOX_OIDC_ISSUER, OPENDOX_OIDC_AUDIENCE,
OPENDOX_OIDC_JWKS_URL or a non-loopback OPENDOX_BIND_HOST beside `local`,
which load_settings and generate-and-open both refuse. They now refuse them
at configuration, before any database is reached.
Seven new cases:
- the three broker settings x {migrate, reset};
- the bind.
All seven fail at 32683e8 and pass here. Full suite: 2538 selected, 2527
passed, 11 skipped, 0 failed.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T070's 525f61c makes `load_migration_settings` refuse what a local install cannot be (Copilot review of openDox-code#67). T072 had already restructured the same lines: under `local`, it asks that refusal and then takes the bundle's migration DSN. The conflict resolves to T072's structure, with T070's reason carried into its comment. The refusal is asked once, before the bundle's DSN is read. The two merged cases now set the local shape as T072 defines it, with the mode and the state dir and no operator DSN. Beside `local` a DSN is itself refused (T072), so a merged case that set one would have tested the DSN refusal rather than the broker or bind refusal it names. Full suite: 2552 selected, 2541 passed, 11 skipped, 0 failed. A mutant that drops the refusal from the migration loader fails all 7 merged cases. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n too (Copilot review) When the runtime extra is absent, `runtime status` returns early, and that return said `broker_keys: "not probed"` for every install. A local install's broker is not configured whether or not the extra is present. That answer comes from the configuration, not from a probe, so the early return now gives the local install the answer the full report gives: `"not configured (local mode)"`, with `broker_discovery: null`. Both returns write it through one helper, so the two cannot drift. A hosted install's early return still reads "not probed", as before (13.6). The branch is covered now, so its `pragma: no cover` goes. A new case runs both shapes with `opendox.runtime.db` absent from `sys.modules`. Before (`525f61c`'s runtime/cli.py): local 1 failed and hosted passed. After: both pass. Four mutants of the fix are killed. Full suite: 2540 selected, 2529 passed, 11 skipped, 0 failed. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T070's 02dadc5 makes `runtime status`, when the runtime extra is absent, report a local install's broker as not configured on the early return too (Copilot review of openDox-code#67). It merges cleanly: T072's `database_bundle` report comes before that return, in another hunk. The merged case sets the local shape as T072 defines it, with the mode and the state dir and no operator DSN. It also asserts that the bundle is reported on the early return (`database_bundle` present for local, `null` for hosted). Full suite: 2554 selected, 2543 passed, 11 skipped, 0 failed. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tings' broker invariants are scoped (Copilot review) A local `status` returns `ok` on the database's verdict alone. Both earlier local cases forced a database fault and asserted exit 1, so a regression that also counted the absent broker as a fault would still have passed. A DB-backed case now runs `status` for a local install against a migrated schema on the suite's own server (`database` and `postgres_dsn`, with the schema selected in the DSN). It asserts `ok` true, exit 0, the database reachable with nothing pending and no drift, and the broker reported as not configured and never probed. Measured: with the local return mutated to `ok=False`, this case fails and the other 40 in the module pass. `RuntimeSettings`' docstring said that a local install's issuer and audience are empty and that a hosted one always carries a real issuer. That is true of `load_settings` alone. `load_migration_settings` carries the migration sentinels in either shape. The docstring now scopes each statement to its loader. Full suite: 2541 selected, 2530 passed, 11 skipped, 0 failed. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…errupts hardened (Copilot review) Copilot's reviews at 95fe16f and 32db5d8 opened ten threads. Eight are fixed here. The two about the server package (PostgreSQL 16.2, no wheel for 3.13) wait on the holder. - Migrations (r4139811473, r4139880241). An explicit OPENDOX_MIGRATIONS_DIR is used as given. Unset, a LOCAL install uses only the copy its own installation carries, and never the working directory's: its entry point runs every migration as the bundle's owner, and the canonical gate pins 0001 alone. Where the installation carries none, it is refused, naming the setting. The installation's copy is the source tree the module was imported from (src/ beside a pyproject.toml naming opendox), then the RECORD of the distribution that holds the running module, and never another one found by name. A HOSTED install's unset default is unchanged (13.6). - The pid (r4139811555). A postmaster.pid is believed only for this data directory's postmaster, as the kernel reports it: an executable named postgres whose working directory is the data directory. Another user's process is never believed. A lock that /proc proves stale is removed before the launch, so a recycled pid no longer holds the bundle. - initdb (r4139880213). It runs into an attempt directory beside the data directory, which is renamed into place only on success. An attempt whose process is gone is removed. A non-empty data directory that holds no cluster is refused and left untouched. - start() (r4139880279). Directories, initialize, launch, wait, bootstrap and migrate are one guarded operation, and every failure is the one named refusal (phase and class name), with anything started stopped. - Interrupts (r4139880267). SIGTERM or Ctrl-C anywhere in the local lifecycle is a clean stop: no traceback, the bundle stopped, the handler restored first. Nothing was served, so the exit is 128 + the signal number. A served run ended by SIGTERM still exits 0. - Refusal wording (r4139880298). Broker settings and operator DSNs are two classes, and each is refused with its own reason. - The test helper (r4139811584). The launch helper is bounded by its deadline, through a selector. Measured with a silent 8 s child and a 1 s deadline: the old loop returned after 8.0 s, the new one after 1.0 s. tests_runtime/test_local_lifecycle.py (new, hermetic) holds these cases, plus a real-server stale-lock case and the helper's own case in test_bundled_postgres.py. Against ac61596's source, 17 of the module's first 18 cases fail. The one that passes is the hosted default, which is unchanged on purpose. All 23 mutants of the fixes are killed. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T070's 859b37b adds a DB-backed case proving that a healthy local `status` exits 0, and scopes RuntimeSettings' broker invariants to their loader (Copilot review of openDox-code#67). It merges cleanly. Here the case uses the local install's own database. Beside `local` an operator's DSN is refused (T072), so the case starts the bundled server on a fresh state directory and asks `status` about it. With the local return mutated to `ok=False`, it fails and the other 42 cases in the module pass. Full suite, with this PR's fourth fix round (5e52872): 2580 selected, 2569 passed, 11 skipped, 0 failed. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…es by name (Copilot review) Copilot's review at ac61596 opened two more threads, both real. - r4139938402: the old fallback believed any pid it could not inspect. So a process that exited between the signal check and the /proc read, or any pid on a platform without /proc, counted as the server. Round 4 already treated a vanished process as gone on the /proc path. This round makes the rule total. `running_pid` believes a pid only when the kernel proves it is this data directory's postmaster. Where nothing can be asked (no /proc: macOS, the BSDs), it believes nothing, and this module does not refuse a start over it. PostgreSQL's own interlocks, the lock file's live-pid check and the shared-memory check, still refuse a second postmaster, so this never yields two servers, and never a refusal over a process that is not one. A lock that cannot be proven stale is left for PostgreSQL to judge. The price on such a platform is a `status` with no pid. That is recorded, not hidden: the standard library has no portable way to ask, and a third-party module here would be an undeclared runtime dependency (test_consumer_reach). Measured before: round 4's source with no /proc, and a python decoy in the data dir, reported the decoy's pid. ac61596's source reported a pid that had already exited. - r4139938444: `Path.expanduser()` raises RuntimeError for an unknown `~user`, and `Path.home()` does the same where there is no home. Both now refuse by name, as ConfigurationError naming OPENDOX_STATE_DIR. A hosted install still never reads the setting and is not refused over it (13.6). Five new cases fail against 28bdccd's source and pass here. Five mutants of the fixes are killed. Full suite: 2584 selected, 2573 passed, 11 skipped, 0 failed. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ed (Copilot review) The module docstring called every case hermetic. Since the fourth fix round, one is not: `test_runtime_status_of_a_healthy_local_install_exits_zero` takes the suite's `postgres_dsn` and `database` fixtures, because a healthy local `status` exits 0 only against a database that answers. The docstring now names that case and says it is skipped without Postgres and fails under CI, like every DB-backed case. It says the rest stay hermetic. Docstring only: the module runs 41 passed. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T070's 026f00e corrects the install-mode module's docstring. It now names the one DB-backed case instead of calling every case hermetic (Copilot review of openDox-code#67). Here that case starts the local install's own bundled server, because beside `local` an operator's DSN is refused, so the merged sentence says so. Docstring only: the module runs 43 passed. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…(Copilot review) The data-files note pointed readers at `opendox.runtime.config.packaged_migrations_dir`, which fix round 4 replaced with `installation_migrations_dir`, the source tree first and then the RECORD of the distribution that holds the running module. The note now names that function and says what it asks. The packaging case now also checks that every `opendox.runtime.config.<name>` pyproject.toml names exists, so a stale pointer cannot come back. Against 4aed627's pyproject.toml it fails, naming `packaged_migrations_dir`. Here it passes. Full suite: 2584 selected, 2573 passed, 11 skipped, 0 failed. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… (Copilot and SonarCloud review) Copilot's review at a0fb7c8 opened three threads, and SonarCloud raised a reliability finding. All four are fixed here. - PG* defaults (r4146787926). libpq fills every parameter a DSN leaves unset from the environment. PGHOSTADDR outranks the socket `host` and sends the connection to TCP, PGSERVICE fills parameters from a service file, and PGOPTIONS sets the session's parameters. No DSN can name every parameter, and an explicitly empty `service` is itself an error. So `bundle.isolated_from_libpq_environment` lifts every PG* variable out of os.environ for the duration and puts it back afterwards. It wraps `generate-and-open --local`'s whole lifecycle and the runtime CLI's verbs under `local`. A hosted install's libpq is untouched (13.6). With PGHOSTADDR=192.0.2.1, PGSERVICE=no-such-service and PGOPTIONS=-c search_path=nowhere set, the real entry point still starts, migrates and serves its own server, and `runtime status` still finds it. - The socket's path (r4146787852). Before the socket directory is chmodded (a chmod follows a symlink), the resolved path is checked. The state dir, postgres/ and run/ must be real directories owned by this user and writable by no one else. Every ancestor must be owned by this user or by root, and must be sticky if every user can write it, or if a group other than this user's own can write it. Anything else is refused by name. - A relative HOME (r4146659876). It is refused for the default state directory, which would otherwise depend on the working directory. - SonarCloud S6466. server_binaries no longer indexes a list. It takes the first search location or none, and both refusal shapes have a case. Against a0fb7c8's source, 8 of the new cases fail and the positive control passes. 11 mutants are killed. Full suite: 2595 selected, 2584 passed, 11 skipped, 0 failed. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…uch for (Copilot review) Copilot's review at 0f77d5c opened two threads. Both were real. - r4147004990: a user's primary group can have other members, so a 0775 ancestor is not private. Every ancestor that anyone else can write, a group included, must now be sticky. The round-7 allowance for the user's own group is gone, and its positive control is now a refusal case. The sticky shape (/tmp) is still the control. - r4147005063: resolving the configured path before checking it discarded the path that was actually configured. A link on that path could be repointed afterwards, while the bundle kept using the unresolved paths. Now: - the ancestors of BOTH the configured path and the resolved one are checked; - every symbolic link on the configured path must be owned by this user or by root; - `..` is refused in OPENDOX_STATE_DIR and XDG_STATE_HOME (and in a derived HOME), so the configured components are the ones the kernel walks. A user's own link to a private directory is still accepted. Against 0f77d5c's source, 5 of the new cases fail and the 4 controls pass. 5 mutants are killed. Full suite: 2601 selected, 2590 passed, 11 skipped, 0 failed. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… peer (RULED 5916000030 items 2, 3)
Brett's rulings on openxFactory#656 (comment 5916000030) cover two things.
Item 2, "pixeltable-pgserver (Recommended)". The `local` extra's carrier is
now pixeltable-pgserver>=0.6.0, the maintained fork of pgserver. Only its
binaries are used, found under pixeltable_pgserver/pginstall/bin. Measured
on the installed 0.6.0 wheel:
- initdb and postgres report PostgreSQL 16.14;
- postgres links libz, libpthread, librt, libdl, libm and libc only, and
initdb links the wheel's own vendored libpq through $ORIGIN;
- the highest GLIBC symbol any binary or server module needs is 2.25, and
the wheels are tagged manylinux_2_27/2_28;
- the licence is Apache-2.0 (dist-info LICENSE and classifier);
- the cp312 x86_64 wheel is 24,704,230 bytes;
- wheels exist for cp310 to cp314.
The lock was re-resolved in a clean environment under the existing pins
less pgserver. The only line that moved is pgserver==0.1.4 ->
pixeltable-pgserver==0.6.0.
Item 3, "Peer auth + accept (Recommended)".
- initdb now runs with --auth-local=peer --auth-host=reject.
- Before every launch the bundle writes pg_hba.conf and pg_ident.conf
atomically, mode 0600. pg_hba.conf holds one local rule, peer map=opendox,
and host reject for IPv4 and IPv6. pg_ident.conf maps the running OS user
(from the password database), and nobody else, to opendox and
opendox_runtime.
- listen_addresses stays empty.
- An OS user name the map cannot hold plainly is refused, as is a uid with
no password entry.
- A cluster that an older build left as trust is put back to peer on its
next start.
The server's own reading proves it. pg_hba_file_rules has exactly those
three rules and pg_ident_file_mappings exactly those two mappings, and
system_user is peer:<os user> for both roles. The same OS user asking for a
role outside the map is refused ("peer authentication failed").
Against 379fbb1's source and packaging, 13 of the new cases fail. Nine
mutants of the carrier and the authentication are killed. The auth mutants
are also killed by the real-server cases alone. Full suite: 2613 selected,
2602 passed, 11 skipped, 0 failed.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…his install (Copilot review) Copilot's review at 84a6c04 made three points, all real. - r4147680113: the tree check left out postgres/data. An existing data directory, a broken link included, now joins the own-tree check: a real directory, owned by this user, writable by no one else, not a link. A link to a cluster elsewhere would otherwise have been given this install's authentication files and launched outside the state tree. A fresh data directory needs no check, because _initialize renames it into place. - Fresh directories and the umask (overview, previously missed). mkdir(parents=True) creates intermediate directories with the default mode less the umask. Under umask 0002, a fresh ~/.local/state/opendox would create group-writable parents, which the tree check then refused. Each missing component is now created on its own and set to exactly 0700, whatever the umask. - Readiness (overview, previously missed). A successful connection proves only that some server answered. Two entry points racing from an idle state both launch, and the loser's postgres lives a moment while the winner's socket answers. So readiness now also needs the data directory's lock file to name this child. Otherwise the wait goes on until this child exits and is refused. One check after the connection is enough, since the lock admits one postmaster per data directory and the socket directory belongs to exactly one data directory. A before-check was tried and dropped: no mutant distinguishes it. Against 84a6c04's bundle.py, all 6 new cases fail. 4 mutants are killed. Full suite: 2619 selected, 2608 passed, 11 skipped, 0 failed. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Main now carries T054 to T058, T055's follow-up (#70) and T056's standalone test. This PR edits src/opendox/runtime/config.py and tests_runtime/test_runtime_cli.py, and main touches neither, so the merge is clean. Full suite on the merged tree: 3061 selected, 3050 passed, 11 skipped, 0 failed. EXPECT_SKIPPED=11 holds exactly, and the floors are met. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T071 (#60) now carries main 047bb4f: phase 2, with T054 to T058, T055's follow-up #70 and T056's standalone test. Git auto-merges cli.py and test_doxbench_entrypoint.py without a conflict: main's _refuse_empty_source_options sits after the install shape is resolved, and --local still precedes --host. Four callers on main relied on generate-and-open's old default, and since this PR an unflagged run is HOSTED and refuses without its broker's issuer. They get --local in the next commit, which T070 owes now that T056 has landed. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…say --local Since this PR, generate-and-open with neither --local nor OPENDOX_INSTALL_MODE=local is a HOSTED install, which refuses without its broker's issuer (#1144 13.4, 13.5). Four cases that landed on main with phase 2 run generate-and-open as the single-user install and relied on the old default, so each now says --local: - tests/test_standalone_generate_path.py (T056), case 3: the server starts, answers and stops. The module docstring names the change and moves F10.1's plain-install run to T077. - tests/test_post_render_validator.py (T058), test_generate_and_open_gives_the_same_verdicts, both fixtures. - tests/test_projection_seams.py (T055), test_generate_and_open_refuses_an_empty_source_option_before_its_run_dir. No case means hosted, so none takes a hosted fixture. Before this commit, all four fail on the merged tree with the hosted issuer refusal; after it they pass. Three mutants of the local path are killed, each failing all four cases: --local ignored, local refusing its own loopback default, and local also asking for the hosted issuer. Full suite: 3117 selected, 3106 passed, 11 skipped, 0 failed. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
cli._report_non_conformance printed the validator's last 20 lines. So a
snapshot that broke one rule many times and a second rule once showed
copies of the first and never named the second. Now each rule id the
report names is printed once, on its own line,
`<count> × [<rule>] <where>: <detail>`, in the order found and with
where it is first broken. The next places that rule is broken follow
beneath it without the id, five in all, then "… and N more of this
rule". One rule can be broken in different ways, and a count beside
the first place alone would read as that place repeated. The
validator's own summary follows, and a report that names no rule id
prints its own last lines as before.
RULED openxFactory#656 5920216845, item 3 ("Show every rule, grouped
(Recommended)"). No #1144 line moves: F7.2 asserts the fixture's rule
id is printed, which stays true.
The new module tests/test_rejection_report.py sits clear of
tests/test_post_render_validator.py, which is T085's. Before the
change: 3 failed, 1 passed. After: 6 passed, with
test_post_render_validator.py still 50 passed.
tests/test_projection_seams.py's envelope-keys case now asserts the
grouped line, that the id appears once, and that the `documents` key is
still named. The always-1 and never-groups mutants each fail 5 cases,
and the drops-places mutant fails 3.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
T070 (#67) now carries T071's merge of main 047bb4f: phase 2, with T054 to T058, T055's follow-up #70 and T056's standalone test. It also carries the four generate-and-open callers that now say --local. Git auto-merges pyproject.toml (main's validator package data beside this PR's local extra and data files), src/opendox/cli.py and tests/test_doxbench_entrypoint.py without a conflict. On their own, the merged callers run --local, and here that starts the bundled server. Three of them would do so under the user's own state directory. The stand-in driver no longer stands in for anything, so three bundled cases fail on this merge alone. The next commit takes both in hand. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…l child keeps its own state Phase 2 is on this stack's base, so four things T072 owed at its merge round are done. - The stand-ins go. tests_runtime/local_entrypoint_driver.py is deleted. Its stand-ins patched names that T055 has since replaced, so on the merged tree they stood in for nothing, and all three background cases failed: the real corpus-root check refused the stand-in corpus, a directory with no repository. test_bundled_postgres.py now launches `python -m opendox.cli generate-and-open --local`, with the validator on, over T050's tests/fixtures/plain-documents copied into a fresh repository, as F13.1's preamble does. - Every cheap refusal comes before the database start. main's T055 added _refuse_empty_source_options to the generate path, so the local path asks it before it builds the bundled server, beside the corpus-root and generated-at refusals. test_projection_seams.py's empty-option case now carries a tripwire bundle, so a regression neither starts a server nor passes. - No child touches the user's state directory. A `generate-and-open --local` child now starts the bundled server, and OPENDOX_STATE_DIR defaults to the user's own ~/.local/state/opendox. tests/standalone_child.py gives every child a fresh, short, private state directory under /tmp and removes it when the child is stopped. Measured before: the three --local children of T056 and T058 initialized a cluster in the (sandboxed) default state home. - T056's case 3 asserts that its bundled server's data directory is under the child's own state directory while serving, and that the directory is gone after the stop. Four mutants are killed. They drop the cheap refusal, the private state dir, its removal, and the fixture's repository. Full suite: 3195 selected, 3184 passed, 11 skipped, 0 failed. Nothing is left under ~/.local/state/opendox or /tmp/odx-child-*. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
It changes cross-leg composition, authorization-adjacent scope behavior, routing, and server lifecycle across 28 files, requiring final human validation of host integration.
Review effort: Balanced
Findings: None
Resolved since last review (1)
|
READY at 213344a — phase 3 is open (T063 landed, #1218 → a883bbf6); T073 landed (#72 → 90ac703). The holder checked: validate, SonarCloud and Copilot's check succeeded; Copilot's latest review at exactly 213344a is "Needs a closer look" with Findings: None, asking for a human check of the host integration; 0 unresolved threads; no closing keywords in the body or any commit. The holder's host-integration judgement: landing T084 on openDox-code main changes no consumer, because openXdox and openxFactory each move their openDox pin only in their own tasks. Retiring the last deferred reaches breaks what those consumers bind (openXdox-code's route handlers, a RouteBindingError, and openxFactory's host wiring) only at those pin moves. T086 (openXdox-code#37, DRAFT-CLEAN against this stack: GateRoutes and ProjectionRoutes under HANDLER_CONTRIBUTIONS, column_contributions at T084's four seams) and T094 (prep done; 65 simulation failures, all owned) carry it. Fix rounds 4–5 close the id-versus-path lookup, the loopback wording, the symlink alias of a settings document, and the TYPELESS_MARKERS reason. #77 contains #72's final pre-squash head 93f77dc and main 90ac703 (merge-tree --merge-base 93f77dc); its diff against main is T084's own files. Dependents #80 (T103) and #81 (T102) are retargeted to main and are not READY. Brett: draft phase 3 ahead, land in plan order when green. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) |
There was a problem hiding this comment.
Sorry @brettheap, your pull request is larger than the review limit of 150,000 diff characters
…sh head #77 landed as e49b17c, a squash on main. Its final pre-squash head was 213344a, which adds fix rounds 4 and 5 over ebe0a35: separate id and path lookups, the loopback-default wording in serve.py's help, the symlink alias of a settings document, and TYPELESS_MARKERS. Its one serve.py hunk (SERVE_DESCRIPTION) does not meet T103's. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#77 squashed onto main as e49b17c, whose tree is 213344a's, the head merged just before this one. So the merge is computed with `git merge-tree --merge-base 213344a`, and both parents are recorded. It changes nothing here: the tree is this branch's own, and #80's diff against main is T103's two files. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#77 moved from d556c3f to 213344a, its last head before it landed as squash e49b17c on main. The move brings T084's fix rounds 4 and 5: - a group's edges are looked up as ids and a selection's files as paths, apart; - an in-root symlink alias of a settings document is moved into the unowned settings section. It touches no file under src/opendox/web/ and not the census, so the merge is clean. The browser's scope mirror follows round 4 in the next commit. Merged, never rebased. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ection's files are looked up as paths #77's final head 213344a keeps default_columns' two namespaces apart: - `ids` answers an id first, and then a path, for an edge written as one; - `paths` answers a path only. A group's edges and a candidate's claimed members are looked up in `ids`, and a selection's files in `paths`, so a selection's file `x` never resolves to the document whose ID is `x`. tileOwnScope now builds the same two maps in the same order, and looks a selection's files up in `paths` alone. Fix round 5 (an in-root symlink alias of a settings document is moved into the unowned settings section) is not mirrored: it is a fact about the file a row resolves to, which the browser cannot see. It joins `resolve_within` as a stated limit. Such an alias is offered here, and the server refuses it by its own scope rule. tests/test_workbench_edit_by_scope.py: the fixture gains two documents. `real-p.md` has the id `p.md`, and another document has the path `p.md`. Group g6 names `p.md` as an edge (so `real-p.md`), and selection s3 names `p.md` and `notes/soil-test` as files: the first is the path `p.md`, and the second is only an id, so it resolves to nothing. The parity table is now 17 tiles. The census row for staging-workbench-model.js is re-measured. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#77 landed on main as squash e49b17c. Its tree equals #77's final head 213344a, which this branch merged in af4adc1. So this merge was computed with `git merge-tree --merge-base 213344a`, and it records main as the second parent without changing a file: the tree is the one before the merge. #81's diff against main is now T102's own files. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…#80) Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Plan 034 (`specs/034-opendox-standalone-operation/`), phase 3: - **T103, every loopback route checks the Host.** Its plan entry is being added to openxFactory#1220. - **From adversarial review 2 (2026-10-03), at openDox-code#77 `b1db1965`:** - **M4 (pre-existing).** DNS rebinding can read the whole corpus. `serve.py`'s `_serve_source`, `/snapshot.json` and the static bundle applied no Host check. Only `/capabilities` and the console routes checked it. - **L2.** The `/capabilities` Host check ran only when a console token had been minted. With no git identity, local mode mints no token, so the install block (`data_dir`, `socket_dir`, `pid`, which expose the OS username) went to any `Host`. - **After:** T084 (#77, landed as `e49b17c3`). `serve.py`'s single-writer order is T055 → T073 → T084 → T103, and T104 follows (RULED `5963851934`). - **Based on `main` since #77 landed** (`e49b17c3`, a squash). The branch was built on #77's `3387293e` and took each #77 head as a merge, never a rebase. The last was #77's final pre-squash head `213344ad`, at `514d9ade`. Then main `e49b17c3` was merged at `b9025b6c` with `git merge-tree --merge-base 213344a`, both parents recorded. `e49b17c3`'s tree is `213344ad`'s, so that merge changes nothing, and this PR's diff against `main` is T103's two files alone. - **Fix round 1** (`978f2646`, Copilot `r4171161548`): an IPv6 loopback bind now works. See "Fix round 1" below. - **Fix round 2** (`77020042`, Copilot `r4173481146`): an IPv6 wildcard bind is announced at `[::1]`. See "Fix round 2" below. Claimed on openxFactory#656 in [`5963901937`](opensoft/openxFactory#656 (comment)). **DRAFT.** The holder posts READY and the landers merge. ## The gate There is one check, in one place: `DashboardHandler.parse_request`. `BaseHTTPRequestHandler.handle_one_request` runs it on every request before it looks for a `do_<METHOD>`. On a loopback plane, every request is refused unless it carries exactly **one** `Host` line naming one of the plane's own loopback authorities at the **bound** port. That holds whatever the route or method: - the static bundle; - `/source/*` and `/snapshot.json`; - `/capabilities`, whatever the token state; - `/workbench/*` and every `/actions/*` route; - a route a host contributes; - HEAD, OPTIONS and any other method. **Accepted:** - `127.0.0.1:<port>`; - `localhost:<port>`, in any case; - `[::1]:<port>`, only where the socket is bound to `::1`. The match is exact, after trimming the optional whitespace around the field value. It reuses `loopback_authorities`, which gains an optional `bound_host`. Its one-argument form answers what it always did. **Refused:** a suffix or prefix match, another port, a missing or empty `Host`, and a second `Host` line. **The answer is one fixed status and body, and it never echoes the `Host`:** `403` with `{"ok": false, "error": "invalid_host", "message": "the request Host does not name this loopback server"}` (`serve.FOREIGN_HOST_BODY`). `invalid_host` is the code `/capabilities` already used. Any declared body is drained first (bounded, like every other refusal here), the connection closes, and the server log gets one fixed line. **A hosted (non-loopback) plane is unchanged.** `parse_request` reads `self.loopback` first and does nothing more when it is false. Hosted mode's own rules (`hosted_ref_refused`, the gateway identity) are untouched. **The boundary: which plane is gated** (the holder's ruling on Copilot `r4171161531`, declined). - **T103 gates the loopback plane**, which is exactly `LOOPBACK_HOSTS`: `127.0.0.1`, `::1` and `localhost`. `serve.py` and `runtime/config.py`'s `LOCAL_BIND_HOSTS` hold that set equal (`config.py:1594-1603`). - **Any other bind is the hosted plane**, like `0.0.0.0`. That includes `127.0.0.2`, `127.1` and `LOCALHOST`. Its `Host` boundary is its deployment's, because the proxy in front of it names the public `Host`. It also mints no console token and opens no session. - **A `--local` install fails closed** on those spellings: it refuses them before it opens a socket (`tests_runtime/test_install_mode.py:256-267`). Reproduced at the base `3387293e`: `generate-and-open refused: --host '127.0.0.2' is not a loopback address ...`, and the same for `LOCALHOST`. **Also changed:** - `_trusted_console_host` and the console's Origin test now read the bound socket's authorities, through the same predicate. The console test now also refuses a duplicated `Host`. - The `/capabilities` arm's own token-conditional Host test is removed. It is dead now: the gate refuses those requests first, and a hosted plane never mints a token. ## Evidence **A real `generate-and-open --local` serve**, run with a clean environment (no `GIT_*`, no `XF_*`) and a private state dir, with no git identity (L2's configuration). Raw requests show status/body bytes per Host. Before, at `3387293e`: ``` token minted: False install: {'mode': 'local', 'database_bundle': {'data_dir': '…/odx-t103/postgres/data', 'socket_dir': '…/odx-t103/postgres/run', 'pid': 2891371}} route good LOCALHOST evil suffix lh.evil otherport noport empty missing dup v6 v6mapped GET /index.html 200/7556 200/7556 200/7556 200/7556 200/7556 200/7556 200/7556 200/7556 200/7556 200/7556 200/7556 200/7556 GET /snapshot.json 200/7290 200/7290 200/7290 200/7290 200/7290 200/7290 200/7290 200/7290 200/7290 200/7290 200/7290 200/7290 GET /capabilities 200/4093 200/4093 200/4093 200/4093 200/4093 200/4093 200/4093 200/4093 200/4093 200/4093 200/4093 200/4093 GET /source/notes-rain-barrel-leak.md 200/447 200/447 200/447 200/447 200/447 200/447 200/447 200/447 200/447 200/447 200/447 200/447 ``` After, at this head (every other route class reads the same, HEAD with `403/0`): ``` GET /index.html 200/7556 200/7556 403/104 403/104 403/104 403/104 403/104 403/104 403/104 403/104 403/104 403/104 GET /snapshot.json 200/7290 200/7290 403/104 403/104 403/104 403/104 403/104 403/104 403/104 403/104 403/104 403/104 GET /capabilities 200/4093 200/4093 403/104 403/104 403/104 403/104 403/104 403/104 403/104 403/104 403/104 403/104 GET /source/notes-rain-barrel-leak.md 200/447 200/447 403/104 403/104 403/104 403/104 403/104 403/104 403/104 403/104 403/104 403/104 OPTIONS /source/… 501/360 501/360 403/104 … POST /actions/workbench/chat-turn 403/103 403/103 403/104 … ``` The same serve was also run with an identity, so a token was minted, and every refused cell reads `403/104` there too. **In a real browser** (headless Chromium 149). `--host-resolver-rules=MAP evil.example 127.0.0.1` simulates the rebinding. The page at `http://evil.example:<port>/` then fetches same-origin: | request | before (`3387293e`) | after | |---|---|---| | `GET /` | 200 | 403 | | `fetch('/source/notes-rain-barrel-leak.md')` | **200, 447 bytes of the document** | 403 `invalid_host` | | `fetch('/snapshot.json')` | **200, 7290 bytes** | 403 `invalid_host` | | `fetch('/capabilities')` (token minted) | 403 | 403 | **The browser path still works.** The page loads at `http://127.0.0.1:<port>/` and at `http://localhost:<port>/` with 45 responses and no page errors, before and after alike. Both before and after, three answers are ≥ 400. All three are 404s that predate this PR: `views/intent-feed.js` (not owed, T075), `/snapshot-index.json` and `/project-register.json`, neither of which a standalone install has. **The mutants, run against `serve.py` itself.** Each one made the new file red: | mutant | failed cases | |---|---| | `/source` exempted from the gate | 5 (both in-process tables, both real-serve tables, the HTTP/1.0 case) | | the port ignored | 12 | | a suffix match | 7 | `tests/test_loopback_host_gate.py` §5 also keeps these mutants in the suite, against a live server: - nine route classes exempted, one at a time; - the port ignored; - suffix and prefix matches; - only the first `Host` line read; - a missing `Host` trusted. Each one has to produce a table violation. ## Fix round 1 (`978f2646`): an IPv6 loopback bind Copilot `r4171161548` (accepted). The gate accepts `[::1]:<port>` only where the socket is bound to `::1`, but no socket ever was: - `build_server` used `http.server.ThreadingHTTPServer`, which is `AF_INET` only. So `host="::1"` (named by `LOOPBACK_HOSTS` and by `LOCAL_BIND_HOSTS`) failed at the bind, and at the base `3387293e` `generate-and-open --local --host ::1` ended in `socket.gaierror: [Errno -9] Address family for hostname not supported`. - `server_url` would have printed `http://::1:<port>/`. The fix: - An IPv6 literal now binds with `_IPv6ThreadingHTTPServer` (`AF_INET6`), and every other host with the standard class, as before (`_server_class_for`). - `server_url` brackets IPv6: `http://[::1]:<port>/index.html`. - Three live `::1` cases were added (below). ## Fix round 2 (`77020042`): an IPv6 wildcard bind is announced at `::1` Copilot `r4173481146` (accepted). Since fix round 1, a `::` bind opens an `AF_INET6` socket, but `server_url` still announced every wildcard at `127.0.0.1`. An `AF_INET6` socket is IPv6-only on some platforms, so the printed URL could name nothing that answers. The fix: `server_url` announces `::` at `::1`, bracketed as `http://[::1]:<port>/`. `0.0.0.0` and `""` stay at `127.0.0.1`. `::` is still a hosted plane, not one of `LOOPBACK_HOSTS`, so the loopback gate does not apply to it, as before. Six cases were added (below). **Copilot's other note.** At `77020042` Copilot listed a "previously missed" HTTP/0.9 note, with no thread and "Findings: None": a two-token `GET /path` gets the fixed refusal body with no status line. That is HTTP/0.9's own shape, since that protocol has no status line or headers. The client still gets the fixed `invalid_host` body, never the document. Measured at `d0f1efcb`: `GET /source/notes-rain-barrel-leak.md` over HTTP/0.9 answers exactly `FOREIGN_HOST_BODY`. It is left as it is. Copilot's review at `d0f1efcb` recommends approval. ## Tests: `tests/test_loopback_host_gate.py` (new, 67 cases) 1. **The table on the pure predicate** (`serve.host_names_this_loopback_serve`): 5 accepted rows and 25 refused rows on an IPv4 bind, plus the IPv6-bind and port-80 cases. The refused rows include `evil.example:<port>`, `127.0.0.1.evil.example`, `localhost.evil`, `evil.localhost`, `127.0.0.1:<other port>`, no port, an empty Host, a missing Host, duplicates in three orders, `[::1]` on an IPv4 bind, `[::ffff:127.0.0.1]`, the long IPv6 form, unbracketed `::1`, and `localhost.`. `LOCALHOST:<port>` is accepted. 2. **The table across every route class of an in-process server**, with a host's contributed GET, prefix GET and POST beside the core routes. It runs with no console token (L2) and with one. Two more cases: a refused 1 MiB body still gets the whole refusal, and an HTTP/1.0 request with no `Host` is refused. 3. **The table across every route class of a real standalone `python -m opendox.cli generate-and-open --local` child**, with no identity and with one. The child has neither sibling importable, its own state dir, and a bundled PostgreSQL that stops with it. - **The IPv6 loopback bind** (fix round 1), in process and as a real `--local --host ::1` child. It binds and prints `http://[::1]:<port>/`. It accepts `[::1]:<port>` and refuses `[::1]:<other port>` on every route class. - **`server_url`** (fix round 2) announces each bind at its own family's loopback, over five bind spellings. A live `::` bind answers at the URL it announces. 4. **The browser path.** Every file the bundle ships, `/`, `/capabilities`, `/snapshot.json`, `/source/*`, and a console request that carries the token and the page's own `Origin`. They run under `127.0.0.1` and `localhost` on an IPv4 bind, and under `[::1]` on an IPv6 bind. 5. **The mutants**, as listed above. 6. **A hosted plane (`0.0.0.0`)** still serves a `Host` the loopback gate refuses. Local run, with the environment cleaned (and, from `b9025b6c`, pytest's basetemp and `TMPDIR` under `~/.local/state`, outside the workspace): - `tests/`: `2668 passed, 11 skipped` at `56aae213`, `2690 passed, 11 skipped` at `978f2646`, `2749 passed, 11 skipped` at `fb0393df`, `2756 passed, 11 skipped` at `d0f1efcb`, and `3093 passed, 11 skipped` at `b9025b6c`. - `tests_runtime/`, with no database: `632 passed, 166 skipped, 0 failed` at `b9025b6c`. The one red from earlier local runs, `test_local_git_adapter.py::test_a_refusal_raised_while_binding_takes_the_operations_own_kind`, also failed at the base `3387293e`. It was an artifact of a temp dir inside the workspace's git tree, and it passes with the temp dir outside it. **The triple pin.** No new case skips, so `EXPECT_SKIPPED` stays 11. The floors are not re-pinned. #77's CI read `selected=3399 passed=3388`, a margin of 923, and the phase-3 PRs leave the floors as T037 set them. ## Cross-repository consequences, for the pin moves past T103 - **openXdox-code `tests/test_edit_action.py:170`** asserts that `POST /actions/edit` with `Host: rebound.example` answers `(403, "agent_invocation")`. Under T103 that request is refused earlier, by the gate, as `(403, "invalid_host")`. The status is unchanged and the code moves. Its `hostile_caps["error"] == "invalid_host"` assertion still holds. - **openxFactory `tests/ideation-dashboard/test_extension_point_parity.py`'s `ROUTE_ARMS`** pins the `/capabilities` arm as reaching `_send_json`, `_serve_bytes`, `_session_repository` and `_trusted_console_host`. After T103 the arm reaches `_serve_bytes`, `_session_repository` and `install_report`. That pin was already stale from T073's `install_report` (#72). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… T084 markers T084 landed, so the five 16.5 cases that ran as strict xfails naming it now pass, and their markers come out in this merge: - the session reads (project register, thread); - the session controls, hidden standalone; - model approval, refused alike and writing nothing; - the document abstract, refused alike. In the same merge: - `_Answer.comparable()` also sets aside the values of `/capabilities`' `install.database_bundle` (plan 034 T073): each `--local` child runs its own bundled server under its own state directory. The block's shape is still compared. - The intake surface's reason is T084's `column_seams.GATE_RECORDS_REFUSAL`, and model approval asserts it too, with `approval_refused`. - `import dataclasses` is restored. #77 dropped it from the named test with the scope stand-in, and T082's section 6 uses it. - `EXPECT_SKIPPED` moves back from 16 to 11: T082 adds no skip. The scope stand-in is main's, unchanged. Local, LANG=C.UTF-8, no PostgreSQL service: the named file 83 passed; the whole suite 3692 passed, 177 skipped, 0 failed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merged, not rebased. It merges cleanly. #77 is on the base, so the served-turn case runs and passes: its strict xfail is dropped, and .github/workflows/validate.yml's EXPECT_SKIPPED steps back from 12 to 11, with its reason. That is main's own pin, since all three drafts T100 waited on are now in. #77 also refuses the console intake standalone when no gate-record writer is registered (5961364221, item 1), so the intake cases' stand-in host in tests/test_model_binding_trust.py now registers a host gate at opendox.column_seams.gate, as #77's own tests do, and unregisters it after. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…sh head serve.py's single-writer order is T084 (#77) -> T103 (#80) -> T104, and this is a merge, never a rebase. #80's last head carries #77's final pre-squash head 213344a and main e49b17c (T084, #77, landed as a squash). Main's squash of #80, 390e2c2, has the same tree as b9025b6. Clean: the one file both sides touch, serve.py, changed apart (the SERVE_DESCRIPTION rewrite that #77's final head brought, Copilot r4173844338, against T104's build_server and serve() paths). No test the merge brings in reads a standalone child's token from /capabilities (grep console_token over tests/ and tests_runtime/). tests_runtime/test_served_bundle.py reads its URL from the first line that starts with http://, which the console line does not. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…cope (plan 034) (#81) Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Plan 034 (`specs/034-opendox-standalone-operation/`), phase 3: **T102**, a new task. Its plan entry is being added to openxFactory#1220 by another writer. **Ruled:** - [`5963618568`](opensoft/openxFactory#656 (comment)), Brett Heap, 2026-10-03: "Edit and chat by scope (Recommended)". The editors and the chat rail appear wherever the scope lets the document be edited. Only creating documents and Save stay behind the gate, so Save is refused by name. - It builds on [`5961651355`](opensoft/openxFactory#656 (comment)), "Tile's own documents editable (Recommended)". openDox's neutral scope default, which T084 builds, marks a tile's OWN documents editable: a group's members, a selection's files, and a candidate's claiming groups' members. Claimed on openxFactory#656 in [`5963868498`](opensoft/openxFactory#656 (comment)). Still a draft. The holder posts READY, and the landers merge. **Based on main**, and the diff is T102's own 10 files. The branch was built stacked on #77's branch, and the holder retargeted it to main when #77 landed. - The branch starts at #77's `3387293e`. - #77 was merged in five times as it moved, never rebased: - `1d6a4f19` at `60393003`; - `b333bf16` at `7535cd3e`; - `ebe0a35f` at `0fbdba67`; - `d556c3fb` at `0ddca7f0`; - its final head `213344ad` at `af4adc13`. - #77 then landed as squash `e49b17c3`. That squash was merged at `b41fbe0a` with `git merge-tree --merge-base 213344a`: the tree is unchanged, and both parents are recorded. - #80 (T103) landed as `390e2c28`, merged at `02e0aa50`. It touches `serve.py` and no web file. - The head is `02e0aa50`. ## The gap The T096 prep run found it: a local AT-R1 browser-half dry run on the phase-3 drafts. A standalone workbench opened read-only, and said "read-only: the create/edit gate is off here, so editing, chat, and Save are not offered". - `canvasOffered()` (`views/staging-workbench.js`) and `presentationPosture()` (`views/staging-workbench-model.js`) both required `createColumn.createGateLive(caps)`. - Only openXdox's `gate.workbench.create` binding answers that, so a standalone install's null column answered `false`. - The docs tile's `edit` verb was therefore disabled, and no editor or rail was ever mounted. This held even though a standalone `/capabilities` reads `gate: false, edit: true, session: true`, and its own scope makes the tile's documents editable. - With the gate forced true in the browser, every T096 check passed. This was the one blocker. ## The design **One posture.** `editingPosture()` is new and pure, in the model. Facts go in and a frozen answer comes out. The facts are: - `governed`: a host's gate column is registered; - `gateLive`: that column's `createGateLive(caps)`; - `surfaceHidden`: the hosted plane; - `editLive`: `/capabilities` `actions.edit`, which must be stated `true`; - `editablePaths`: the scope's answer. The shell reads it through `editingNow()`. `canvasOffered()` is now `!!scope && editingNow().editors && keyed`. | Facts | Mode | Editors and rail | Create | Save | |---|---|---|---|---| | a gate column, gate live | `gate` | yes, every document loadable | yes | the governed Save | | a gate column, gate off | `read-only` | no | no | none | | no column, `edit` true, something editable | `scope` | yes, only the scope's documents | absent | **visible, refused by name** | | no column, `edit` true, nothing editable | `nothing-editable` | no; the note says why | no | none | | no column, `edit` not true | `read-only` | no | no | none | | the hosted plane | `hidden` | no | no | none | **The scope's answer.** `tileOwnEditablePaths()` mirrors `default_columns.resolve_scope` and `editable_paths`. It reads the same snapshot fields in the same order, resolves and deduplicates, and gives up on the WHOLE tile where the server's `_canonical` would raise. A node-and-Python parity test holds the two together, tile by tile. - The canvas's projection reads it through `doxbenchScopeProjection(..., { editableBy: "tile" })`. With that option, `context_paths` and `editable_paths` are the tile's own documents, as the server's neutral projection has them. - The docs tiles read it as a per-document set (`docWheelEntries(scope, { editable })`). The `edit` verb is offered on exactly the scope's documents. Every other document states "this document is context in the opened tile, not one of the tile's own, so it is not offered for editing here". It never fails on press. **What stays behind the gate:** - **Creating a document** stays absent standalone. The null column mounts nothing, as before. - **The document abstract's generation** stays absent. Ruling 7.7 keys it on the gate, and the route refuses at its step 1 without it. So `capable` gains `createColumn.createGateLive(caps)`, which is implied wherever the gate offered the canvas before. - **Save stays visible, and is refused by name.** I chose that over hiding it, for three reasons: - The ruling's own words are "Save is refused by name". A hidden control refuses nothing; it just isn't there. - RULED Q10's fallback for a shell with no gate column is "a refusal-shaped fallback, never a blank". - A human who has typed into a buffer must be told why it does not persist, at the control they press. So the standalone Save transport (`app.js` `refusalTransport`) now carries the model's `GATELESS_SAVE_REFUSAL`: "Save needs the first-edit transport, and no column on this install contributes it (`gate.workbench.session`), so a governed Save cannot be sent. Your edits stay in this browser's buffers, unsaved." It used to end "Run the CLI verb in your pinned checkout", a remedy a standalone install does not have. It names only the session transport because `app.js` picks this fallback on that half alone (Copilot's second review, below). The canvas Save shows "Save refused -- <that sentence>", and the docs tile's Save shows "the governed Save did not land — <that sentence>". The text stays in the buffer. The pill reads `editing by scope`, and the posture note states the by-scope plane fact beside the chat rung's. **A governed host is unchanged, by construction.** Where a host registers either gate column, create or session, `editingPosture` returns the gate's answer: - `editors` is `createGateLive(caps) && !sessionSurfaceHidden(caps)`, the old conjunction; - the projection, the tile entries, the pill and the posture ladder take their old paths, because the by-scope options are passed only where no column is. The by-scope arm is openDox's own default and never a host's. So a governed host whose gate is off stays read-only even with `edit: true`. This also covers a composed openXdox host before T086 contributes its gate routes, where `gate` reads false, and a host that registers only the session column, whose create gate reads off. **The neutral scope moved under this PR, and the mirror follows it.** #77's later rounds changed `default_columns`, so the mirror follows each of them in its own commit. Parity now compares the whole tile projection (`context_paths`, `editable_paths`, `active_document_candidates` and `outline_path`) with `resolve_scope` over 17 tiles. - **M1** (`b333bf16`; followed at `770156ce`): openDox's own settings documents, `default_columns.SETTINGS_DOCUMENTS`, are never a tile's editable material. They are the model-provider bindings and the model declarations. - The mirror spells them as `OWN_SETTINGS_DOCUMENTS`, and a case holds the two spellings equal. - It leaves them out of the editable set and puts them at the end of the projection's context, as `resolve_scope` keeps them readable in a trailing section nothing owns. - **Fix round 3** (`ebe0a35f`; followed at `3528b2fa`): a group's edges name documents by ID, and a selection's files by PATH. The mirror now resolves every reference through the same index as `_document_index`: id first, then path. - **Fix round 4** (`213344ad`; followed at `ce85d573`): the two namespaces are kept apart. A group's edges and a candidate's claimed members are looked up in `ids`, which answers an id first and then a path. A selection's files are looked up in `paths`, which answers a path only, so a selection's file `x` never resolves to the document whose ID is `x`. - **Fix round 5** (`213344ad`) is **not** mirrored: an in-root symlink that reaches a settings document is moved into the unowned settings section. That is a fact about the file a row resolves to, which the browser cannot see, so it is a stated limit below. **Copilot's first review, two fixes** (`e2d106e8`; both threads answered and resolved): - **By scope, the canvas sends no outline** ([r4173502649](#81 (comment))). The neutral scope projects none (`resolve_scope` returns `outline_path=None`), and the turn guard's `_require_buffer_binding` requires the outline buffer's path to equal it. A tile-mode projection that kept a staged or candidate tile's primary file as the outline would have had every turn there refused. `doxbenchScopeProjection` now derives no outline when `editableBy` is `"tile"`, which also returns that file to the active document candidates, as the server has it. A governed host's projection is unchanged. With no outline buffer by scope, the outline tab's add-section controls are inert, bind no listener, and say why: "adding a section writes into an outline buffer, and here this tile's own <files> are edited directly, with no outline buffer: open the file with its edit verb instead". - **A restore is held to the scope** ([r4173470792](#81 (comment))). The canvas restores persisted buffers verbatim, and that restore is the one route into the loaded set that the scope does not gate (for example, after a regenerated snapshot drops a member from the group). Once the canvas is ready, the shell reconciles the restore with the projection it mounted over, by scope only: - a CLEAN buffer that is no longer the tile's own is unloaded; - a DIRTY one stays, because unloading it would lose the human's text. The posture note names it and says to copy the text out and unload it, since a chat turn that carries it is refused by the server's scope check. The note goes away once the buffer is unloaded. **Copilot's second review, two fixes** (`257853de`; both threads answered and resolved): - **Either gate half makes a host governed** ([r4174293974](#81 (comment))). The two bindings resolve independently, and `app.js` sends Save through a contributed session column's `firstEditTransport` whenever one exists. So a host that registered only the session half used to read as standalone: it edited by scope, and its Save was not the by-scope refusal. Now `governed` is `createColumn !== NO_CREATE_COLUMN || sessionColumn !== NO_SESSION_COLUMN`. Such a host has no create column, so its gate reads off and it stays read-only, exactly as before T102. - **The Save refusal names only the missing session transport** ([r4174293950](#81 (comment))). A host with a live create column and no session column reaches the same fallback, and there "this install has no create gate" would be false. The sentence is quoted above. **One small consequence in `views/doc-wheel.js`.** Only a LOADED buffer's ownership outranks the tile's own answer now. The shell answers an unloaded path with a placeholder `owned: true`, which used to make a by-scope context tile read "load this document for editing before saving it". Under the gate, no entry carries `owned`, so this reads as before. ## Gap G8: the display text - `views/lens.js`, the plan-only note: it was "The tested engine (lens.py) materialises this through the boundary; the read-only surface confirms the plan — nothing is written from the browser." It is now "Shown for confirmation only: this console cannot carry the plan out from the browser, so nothing is written." - `views/lens.js`, the persist pane: it was "Persisted through the interactivity boundary to ideation/workbench/ (gitignored). Nothing enters the register or any queue from here." It is now "Each button shows its plan below before anything is written; this pane itself writes nothing." The now-unused `WORKBENCH_DIR` import goes with it. - `index.html`, the about dialog: "one repository's ideation governance state" is now "one repository's Markdown files and how they group". "read-only" goes too, since the workbench now edits. - `views/wheel.js`, the workbench verb's title. This one is beyond the three named sites, because it became false. It said "scoped to this cluster (read-only)" and now says "scoped to this group", using the facet's word, not the seam key. Not touched, and named here so that nobody reads them as done: `lens-model.js`'s `PENDING_PROPOSAL_NOTE` ("cross-reference queue", "topic cluster"), which is pinned byte-identical to `lens.PENDING_PROPOSAL_NOTE` on the Python side, and the plan's `lands at: ideation/workbench/…` line, which is `workbench.WORKBENCH_DIR`. ## Tests **`tests/test_workbench_edit_by_scope.py` (new), 44 cases, in three layers:** 1. **The posture matrix, pure, under node.** It covers every cell of gate on/off × edit on/off × document editable/not, with the governed column as the fourth fact. - It checks each cell's mode, editors, create, Save, and both `documentEditable` answers. - It covers the hosted plane, and `edit` absent rather than false. - It checks `presentationPosture`: a governed caller's answers are byte-identical to before, with and without the new fact; the by-scope rungs carry `scopeNote`; and the nothing-editable rung names no gate. - It checks the tile projection against the host projection, and the per-document tile entries. 2. **Parity with the server.** `tileOwnEditablePaths` and the whole `editableBy: "tile"` projection (context, editable, candidates and `outline_path`) are compared with `default_columns.resolve_scope(...)` over 17 tiles. The projection runs with the shell's own outline derivation (`primaryFragmentPath`), and a guard case shows that a governed host's projection of the same selection does keep its outline (`sel.md`), so the tile-mode `None` is reached rather than vacuous. The tiles include: - a member that is not catalogued; - a citation that is also a claimed member; - an empty group; - a candidate claimed by an unknown group; - a path the scope cannot name (the server raises, and the browser offers nothing); - a group naming both settings documents (M1); - a document whose id is not its path, named by id from a group and a candidate and by path from a selection (fix round 3); - one document whose ID is `p.md` and another whose PATH is `p.md`. A group's edge `p.md` names the first, a selection's file `p.md` names the second, and a selection's file that is only an id resolves to nothing (fix round 4); - unknown tiles. A guard case stops the table from agreeing vacuously, and a case holds `OWN_SETTINGS_DOCUMENTS` equal to `SETTINGS_DOCUMENTS`. 3. **The real shell**, `mountStagingWorkbench` over the shared DOM instrument: - **Standalone**, with the null columns exactly as `app.js` hands them down and a standalone `/capabilities`. The canvas and rail mount and send stays disabled with no model. The pill and note show. Create and generate are absent. The tile's own document loads. Save is refused by name from the canvas AND from the tile, and the typed text survives. A candidate's citation states the absence, while its claimed members load. A tile with nothing of its own stays read-only and says why. Without `edit`, the workbench is read-only. - **Governed**, with the contributed column the other shell harnesses mount. With the gate live, it has the gate pill, the canvas, the rail, the generate control, and every document loadable. With the gate off and `edit` true, it is read-only with the old note. - **Copilot's first review.** S7 loads both of a group's members, types into one, and restores the session into a regenerated snapshot where the group holds another file: the clean buffer leaves, and the dirty one stays and is named in the note. S8 opens a selection's outline tab by scope: every add-section control is disabled, has no listener, and states the absence. - **Copilot's second review.** S9 mounts a host that registers only the session column: read-only, with the governed host's old note. A case holds the Save refusal to the session transport, and never to the create gate. - Each scenario records its own failure, so a regression names its step. **Unchanged and passing, as the proof that a governed host is untouched:** `tests/test_doxbench_view.py`, `tests/test_doxbench_composition.py` (including F3, the gate-off workbench), `tests/test_doxbench_abstract_pane.py`, `tests/test_doxbench_context_panes.py`, `tests/test_outline_tab.py` and `tests/test_gate_loop_contributed.py`. - One source pin is **re-pinned**, `test_staging_workbench_composes_the_doxbench_canvas_without_new_transport`. It asserted the literal old conjunction. It now asserts `editingNow().editors` and that the gate and hidden predicates are still read off the same `caps`, and its comment gives the reason. - `tests/fixtures/web_boundary_census.yaml`: six rows are re-measured with a provenance sentence each, and the totals are re-derived. `views/lens.js` stays `?`. **Mutants**, at the head `02e0aa50`. Each is applied to the committed tree, and an anchor that is not found aborts the run, so a mutant that never applied cannot read as one that survived. Then the new module, `test_doxbench_composition.py` and `test_doxbench_view.py` run. All twelve are killed. | Mutant | Result | |---|---| | the old gate-only posture (`canvasOffered` back to `createGateLive(caps) && !sessionSurfaceHidden(caps)`) | 9 failed | | editable-everything, in the posture (no nothing-editable rung; by scope, every document loadable) | 3 failed | | editable-everything, in the scope (the scope answers every listed document) | 19 failed | | the settings documents editable again (M1 not mirrored) | 2 failed | | references looked up by path only (fix round 3 not mirrored) | 4 failed | | the tile-mode projection keeps an outline (r4173502649) | 4 failed | | no reconciliation of a restore (r4173470792) | 1 failed | | the reconciliation also discards a dirty restored buffer | 1 failed | | by scope, the outline tab offers a live add-section | 1 failed | | only the create half makes a host governed (r4174293974) | 1 failed | | the Save refusal claims the create gate is missing (r4174293950) | 1 failed | | one index for every reference, so a selection's files are looked up as ids too (fix round 4 not mirrored) | 2 failed | **The whole suite**, locally, with `LANG=C.UTF-8 python -m pytest -q` and the basetemp under `~/.local/state`: - at the head `02e0aa50`: `3769 passed, 177 skipped`, 0 failed (the merges of #77's final head and #80 bring their tests); - at `b41fbe0a`: `3702 passed, 177 skipped` (run in two halves); - at `0ddca7f0`: `3693 passed, 177 skipped`; - at the branch point `3387293e`: `3222 passed, 177 skipped`. The skip count does not move. As #67, #69, #72 and #77 did, this PR does not edit `validate.yml`. ## AT-R1, the browser half, on a local integration The integration the brief names (main plus #69, #72, #77, #74 and this branch) is now this branch's head itself. Every other part has landed on main, and `02e0aa50` contains main `390e2c28` (which also carries #80, T103: every loopback route checks the Host). So the local integration branch (never pushed) sits at `02e0aa50`, and the venv was reinstalled from it (117 installed files compared, 0 differ). The harness is the prep run's `run-pass.sh` and `t096_drive.py`, unmodified except for paths. It ran on a fresh venv with `.[local]`, a fresh state dir per pass, and `TMPDIR` off `/tmp`. **No diagnostic patch was used**: the create gate is NOT forced. All 15 checks passed in each pass. | Check | Pass a (`plain-documents`) | Pass b (plain notes, no front matter) | |---|---|---| | load, wheel, lens (4 checks), grouping tile, workbench verb, workbench opens | PASS | PASS | | **step 7:** the rail shows "No model configured…" with `opendox model-binding add`, before any turn | PASS | PASS | | **step 7:** a turn is refused `model_capability_unavailable` (the composer and send are reachable, and send is disabled; the HTTP turn answers 403 `model_capability_unavailable`) | PASS | PASS | | **step 7:** both editors stay usable (Outline and Document typed) | PASS | PASS | | zero `pageerror` | PASS (0) | PASS (0) | | nothing undeclared (the three declared 404s only) | PASS | PASS | | no 5xx | PASS | PASS | `FAILED CHECKS: []` in both passes. The workbench pill read `editing by scope`, no process referenced either state dir after the stop, and the product removed its own run dir (G7). Three earlier runs gave the same verdict, 15/15 in both passes: on `0ddca7f0`, on `c61fef3f` (this branch at `3528b2fa`, #73's head and main `8e377823`), and on `34fbf96f` (this branch at `60393003` with main `9a490405`). ## Known limits - **Facts the browser cannot see:** whether a catalogued path still resolves inside the checkout (the server's `resolve_within`), and whether a path is an in-root symlink to a settings document (#77's fix round 5). Such a document, deleted after the snapshot or an alias, is offered by the browser and refused by the server's own scope rule. - **A restored outline buffer** is not reconciled: the outline is reserved and cannot be unloaded. By scope, the canvas mounts its outline with no path, so only a session persisted by this branch before `e2d106e8` could carry one. - **The holder's T104 note**, "reopen the console file" in place of "reload the page": not taken. It depends on T104's opener file, which is not in this branch's base. At this base, "reload the page" is still correct. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Plan 034's **T082** (`specs/034-opendox-standalone-operation/tasks.md`) realizes #1144's **16.5**: *"Every other surface works with no model. Documents, generation, the views, sessions and saving answer exactly as they do with a model configured."* Its falsifier is `tests/test_chat_model_configuration.py`. Its **After** line is T081, T084 and T085, and all three have landed. **Base: `main`.** This PR is no longer stacked. The predecessors landed as: - #71 (T085) as `2680eb5e`; - #74 (T081) as `9a490405`; - #77 (T084) as `e49b17c3`; - #80 (T103) as `390e2c28`; - #81 (T102) as `0116293a`; - #85 (the T102 follow-on) as `c4b55cc4`. The branch takes `main` with merge commits only (`7332be52`, `c002fac0`, `87a753a8`, `a25606bb`, `9948dc2b`), never by rebasing. It was drafted under Brett Heap's word of 2026-10-02 (`openxFactory#656` comment `5960162524`, *"Draft all of them now (Recommended)"*) and claimed in `#656` comment `5962404984` (P3-N: T082, with T083 as a local check only). The holder posts READY, and the landers merge. ## Rulings - R1Q10 (a), `5850003126`: each consumer mechanism gets openDox's own neutral default. - `5961364221` item 1: standalone, model approval refuses by name, and the intake surface answers `offered: false` with the reason. - `5961651355`: the standalone scope marks a tile's own documents editable. Saving still needs a live session, and sessions are reached only through the host's gate verbs. - The holder's shape for this PR: - send the same standalone request twice, once with no model configured and once with a binding declared through `model-binding add`; - assert that each answer is an answer, never a dropped connection, and that the two are equal; - mark the cases that need T084 `xfail(strict=True)` until it lands. It has landed, and the markers are gone. - **The holder's ruling on this writer's RULING NEEDED, 2026-10-02: option (a).** - openDox's standalone corpus default leaves out openDox's own settings documents. - The list is declared once, beside the path constants, by importing them, not by copying the strings. - A host's own adapter decides for itself, and 16.5's text gets no exception. - The holder asked that the exclusion live outside the single-writer files where possible, and that the tests compare the full corpus. - The holder named two mutants: one that drops the exclusion, and one that widens it to the directory. ## The test: one request, two postures The new section 6 of `tests/test_chat_model_configuration.py` starts two standalone `generate-and-open --local` children over the **same commit**, with sibling imports refused (`tests/standalone_child.py`): - **"no model"**: a fresh fixture repository, no binding, and no `omp` on the PATH. - **"a binding"**: a byte copy of that checkout, `.git` included, after `python -m opendox.cli model-binding add --repo-root <copy> ... -- a-broker` declared a binding (exit 0, nothing refused). That is how a standalone user configures a model. Each request goes to both children: - Each answer must be an HTTP response. - No sibling import may be refused while the request is made (`Child.refused()` is read before and after). - The two answers must be equal in status, content type and body. - A JSON body is compared as data. - Only `/capabilities` has values set aside, because they are per-process by design: its `console_token`, and the values of its `install.database_bundle` (`data_dir`, `socket_dir`, `pid`; plan 034 T073). Each `--local` child runs its own bundled server under its own state directory. The block's shape is still compared: the same keys, and a value on both sides or on neither. - Every other JSON surface is compared whole, so a key that differs between the postures there fails the case (`c6689c55`). | surface | requests | result | |---|---|---| | documents | `GET /snapshot.json`; `GET /source/<doc>`; the keyed `GET /source/fixture@main/<doc>`; the bare `GET /source` (refused alike); `POST /actions/edit` (select-to-edit, with `EDITOR=true`) | equal | | generation | `python -m opendox.cli generate` over each checkout, as a lone openDox, with the same no-`omp` PATH the servers started with. The two outputs must be byte-equal, and equal to what each posture serves | equal | | the views | `/index.html`, `/app.js`, `/styles.css`, `views/display.js`, `wheel.js`, `wheel-model.js`, `doc-wheel.js`, `lens.js`, `lens-model.js`, `/capabilities`; plus `buildWheelModel`, `buildLensModel` and `docSummaries`, run in node over each posture's served snapshot | equal | | sessions | `POST /actions/gate/share-session`, `abandon-session`, `open-pr`: refused alike, and neither checkout's HEAD or status changes | equal | | session reads (T084) | `GET /project-register.json` and `GET /workbench/thread?...` answer through openDox's seams | equal | | session controls (T084) | `/capabilities` reads `actions.gate` and `actions.refresh` false standalone (`5920216845` item 1) | equal | | saving | `POST /actions/gate/first-edit`, `edit-document`, `create-document`: refused alike, and nothing written (`5961651355`) | equal | | model settings (T084) | `GET /workbench/model-intake`: `offered: false`, with T084's `column_seams.GATE_RECORDS_REFUSAL`. `POST /actions/workbench/model-approval`: `approval_refused` with that reason, and nothing written. `POST /actions/workbench/document-abstract`: `model_capability_unavailable` (`5961364221` item 1) | equal | **The five cases that waited for T084.** Until #77 landed, five cases ran as `xfail(strict=True)` naming T084, because the request dropped the connection standalone or the capability still read true: - the two session reads; - the session controls; - model approval; - the document abstract. The merge of `e49b17c3` (`c002fac0`) removed the markers, and all five pass. The same merge: - set aside the `install.database_bundle` values above; - moved the intake reason to `GATE_RECORDS_REFUSAL`; - restored `import dataclasses`, which #77's hand-applied scope stand-in dropped from the file and which section 6 uses. A clean textual merge would have left a NameError. The scope stand-in is `main`'s, unchanged. **What these cases do not assert.** The gate verbs answer `404 unknown_action` in both postures. The sessions and saving cases assert only that each verb is refused alike and writes nothing. They do not assert a particular refusal code: T084 keeps the gate column a host's contribution, and its standalone answer is T084's to decide. ## The fix the cases needed: openDox's settings documents are not the user's documents **Measured at #74's head `9061b22a`.** `opendox model-binding add` writes its bindings document into the checkout, at `ideation/dashboard/model-provider-bindings.yaml`, where its operator can read and commit it. openDox's standalone corpus reads the working tree (`WorkingTreeCorpus`, ruled *"Working tree (Recommended)"*), so that document joined the corpus as a `source` document: - `GET /snapshot.json` had sha `52a09ac1` with no model and `45d01a78` with a binding. - The `generate` verb gave the same two digests. - The wheel and the lens built from the two snapshots differed with them. - Committing the file, as its own docstring intends, would list it all the same. **RULED (a), and placed outside the single-writer files:** - **The declaration.** `doxbench_intake.SETTINGS_DOCUMENTS` declares the two paths, in `doxbench_intake.py` beside `DEFAULT_DECLARATIONS_RELPATH`. It reads `binding_mod.DEFAULT_BINDINGS_RELPATH` and `DEFAULT_DECLARATIONS_RELPATH`, and copies neither string. - **The listing.** `WorkingTreeCorpus` (`runtime/local_git_adapter.py`) takes `excluded`, the exact keys its listing leaves out, and its default is that tuple. - It is applied after both listing branches, so tracked files, untracked files and a pinned revision all leave out the same paths. - A whole-corpus `check()` leaves them out too (Copilot r4170556938). A subject the caller names is still answered. That is all the filter does. Every other finding the parent reports stands as before, including a tracked path deleted from the working tree, which the listing omits and the parent's diff still reports. - `excluded=()` lists everything. - **One declaration, two layers (`7af4c999`).** T084's `default_columns.SETTINGS_DOCUMENTS`, which keeps the same documents out of every owned scope section, now builds its set from `doxbench_intake.SETTINGS_DOCUMENTS` instead of a second literal. It is a separate commit so that it can be reverted alone: it touches T084's `default_columns.py`. - **Every caller of `WorkingTreeCorpus` is openDox's standalone default.** In `src/`, the class is constructed only by the twin `_default_home_factory` in `cli.py` and `serve.py`. A GitHub code search of `opensoft` found it in no other repository's code, but that search covers default branches only. So the class default is that default's rule. - **Cost.** `local_git_adapter` still costs the standard library alone to import. It now also names `opendox.doxbench_intake`, itself stdlib-only. - **The source route.** A file left out of the listing is still a file, and `/source/<path>` still serves it by name. The rule is about what the corpus lists. - **The comments in `cli.py` and `serve.py` (Copilot r4174646638).** These are documentation-only changes in two single-writer files: - the twin `_default_home_factory` docstrings no longer say `check` is inherited unchanged; - `cli.py`'s import comment now names `opendox.doxbench_intake`. **The settings cases, over the full listing as written, with nothing subtracted:** - `test_openDoxs_settings_documents_are_declared_once` checks: - the two constants; - that `WorkingTreeCorpus`'s default is that very tuple (`is`); - that `default_columns.SETTINGS_DOCUMENTS` equals it as a set. - `test_the_standalone_corpus_lists_neither_settings_document`, through each entry point's own `_default_home_factory`: - the bindings document and the declarations document are not listed: untracked, then **committed**, then at that commit as a pinned revision; - a user's own `ideation/dashboard/notes.md` **is** listed; - so is an `ideation/dashboard/archive/model-provider-bindings.yaml` that has the bindings document's file name. - `test_a_corpus_told_to_leave_out_nothing_lists_every_file`. - `test_a_whole_corpus_check_names_only_what_the_corpus_lists`. ## Falsifier, failing before and passing after **Before.** The branch's test file was run over #74's head `9061b22a` sources. The result was `7 failed, 68 passed, 5 xfailed`. The failures were the four settings cases plus the snapshot, generation, and wheel-and-lens cases. The other surfaces already answered alike there, so their cases keep that property named. **After**, at `87a753a8`, at `a25606bb` and at the head, the named file has `83 passed`, with no xfails. ## Mutants Mutants were applied one at a time by a harness that restores each file and checks its digest. Each run used only the cases meant to kill it. **At the head `9948dc2b`, all 20 were killed in one run** (`tools/mutants-t082-full.json`), and each by the assertion it targets: - **K01**: `/capabilities` discloses whether a model is configured. - **K02b**: the intake surface offers intake where a binding is declared. K02 was re-anchored on T084's line. - **K03**: select-to-edit refuses where no model is configured. - **K04**: a document's source is served differently where a model is configured. - **K05**: an action no route claims answers 200 where a model is configured. - **K08**: the working-tree corpus ignores `excluded`. - **K09**: a pinned revision lists the settings documents. - **K10**: the declarations document is missing from the list. - **K11**: the exclusion drops the whole corpus. - **K13**: the standalone default leaves out nothing. This is the holder's first mutant: drop the exclusion. - **K14**: the exclusion is widened to the settings documents' directory. This is the holder's second mutant. - **K15**: the exclusion matches by file name. - **K16**: K13 again, run against the 16.5 HTTP cases alone. - **K17**: a whole-corpus check reports an excluded path. - **K18**: the check also leaves out a path the caller names. - **K19**: the scope default's settings set drifts from the one declaration. - **K20**: the intake surface's reason is the broker notice, not the gate seam's. - **K21**: model approval refuses under the intake code. - **K22**: `actions.gate` reads true standalone, so the session controls are shown. - **K23**: the intake surface carries a `console_token` key that differs by posture. It survived the earlier comparison, which dropped `console_token` from every JSON answer. It is killed by the `/capabilities`-only comparison (`c6689c55`). ## CI pins - **`EXPECT_SKIPPED` stays 11.** While the five T084 cases were strict xfails it read 16, because JUnit writes an xfail as a skip. The merge that removed them moved it back. - **The floors are re-pinned** by the workflow's own rule: three below the lower of two greens of one tree. - They were re-pinned last in `0b0f038e`, over the tree `a25606bb`, which is T082 with `main` at `0116293a`. - Run `37152269851` read `triple: selected=3980 passed=3969 skipped=11 failures=0 errors=0` on attempt 1 (job `111288409713`) and on its re-run (job `111296677742`). - So `MIN_SELECTED` is now 3977 and `MIN_PASSED` 3966. The re-pin before that, `c6703779` over `87a753a8`, read 3936/3925. - The floors had not moved since T037, so the declared three-test margin had grown to several hundred (Copilot r4170556888, r4174447721). - T082's later commits add no case. CI at `0b0f038e` read 3980/3969/11, a margin of 3. - At the head `9948dc2b`, CI (run `37159199209`, job `111308882309`) read `triple: selected=3987 passed=3976 skipped=11 failures=0 errors=0`. The margin of 10 is the 7 cases #85 brought in with `main`. A PR that lands later re-reads the floors by the same rule. ## The whole suite All local runs were in the foreground, with `LANG=C.UTF-8`, no `GIT_*` or `XF_*` variables, and no PostgreSQL service, so the runtime cases skip. - `87a753a8`: `3759 passed, 177 skipped`, which is 3936 selected, as CI reads. - `a25606bb`: `3803 passed, 177 skipped`, which is 3980 selected, as CI reads. - The head `9948dc2b`: `3810 passed, 177 skipped`, which is 3987 selected, as CI reads. ## Review rounds - Copilot at `947411fd`: two findings, both fixed (`92275fcd`, `dcdb7554`), answered and resolved. - Copilot at `dcdb7554` and `7332be52`: no open findings. - Copilot at `7af4c999`: r4174447721, floors not re-pinned after T084. Fixed in `c6703779`. - Copilot at `87a753a8`: r4174646638, the twin factory docstrings still listed `check` as inherited unchanged. Fixed in `7a022068`. - Copilot at `0b0f038e`: one open finding and two "previously missed" notes. - r4174697909: this description still described the stacked phase (xfails, `EXPECT_SKIPPED` 16, `NO_BROKER_NOTICE`). It is rewritten here. - The generation case ran its `generate` children on the ambient PATH, because the `postures` fixture restores PATH once its servers start. It now uses the same no-`omp` PATH (`47e8bb95`). This machine has no `omp`, so the output did not change here. - `check()`'s docstring said a whole-corpus check covers exactly what `list_documents` lists. That was broader than the code. The filter leaves out only the excluded settings documents. A tracked file deleted from the working tree is omitted from the listing, and the parent still reports it as a divergence. That reporting dates from before T082. `47e8bb95` narrows the docstring to what the code does, and does not change that behavior: dropping a real uncommitted deletion from the verdict would be a different rule from 16.5's. - Copilot at `47e8bb95`: Findings: None. It made one "previously missed" note: `comparable()` dropped a top-level `console_token` from every JSON answer. Fixed in `c6689c55`, which compares every surface but `/capabilities` whole (mutant K23). - Copilot at the head `9948dc2b`: "Needs a closer look", Findings: None. Its one note was that this description was stale, and this revision answers it. No review thread is open. ## Not in this PR - T083 (16.6, then F16.1 whole), which runs at landing. This writer's local check of it was green and was reported separately. - The holder's bookkeeping: the plan box for T082. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…plan 034) (#82) Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability **Plan 034 T100. #1144 box 16.3a: a served repository's bindings are trusted per machine.** RULED by Brett Heap on 2026-10-02 in openxFactory#656 comment `5962785556`, item 2: *"Trust per machine (Recommended)"*. The text this conforms to is T007 batch M (openxFactory#1219, merged as `cc775fea`): box 16.3a and F16.1's batch M block. What openxFactory registers when it hosts openDox was RULED on `5970369724`; that registration is T094's (below). Phase 3. The base is `main`. T100's After line (T080, openDox-code#64 and T084) is met: #64 landed as `8e377823`, and #77 (T084) as `e49b17c3`. - This branch merged #64's final pre-squash head `04bcb68e`. - It then merged `main` `8e377823` with `git merge-tree --merge-base 04bcb68`, both parents recorded. - It then merged `main` `e49b17c3` (#77), `390e2c28` (#80), `0116293a` (#81), `c4b55cc4` (#85) and `ca9e1bd5` (#76) with ordinary merge commits. The branch held none of #81's, #85's or #76's commits. Nothing was rebased. The diff against `main` is T100's 12 files only. ## The defect The entry points read the bindings document of the repository they SERVE (`declared_model_port_factory` over `bindings_path(checkout_root)`). A binding written into that file by hand, or arriving with a clone, needed no approval. The adversarial review of 2026-10-02 showed two things: - A committed `broker_argv` of `["/bin/sh", "-c", "id > $PWD/pwned"]` ran on the first chat turn. - A committed `env:` reference sent an unrelated secret of the operator's to the file's endpoint as a bearer token (`repo_binding_exfil.py`). The first two cases of `tests/test_model_binding_trust.py` are those two findings, run as the review ran them. On `main` `8e377823` without this change, they fail for the defect's own reasons: ``` AssertionError: the endpoint was contacted, and was sent ['Bearer cloud-secret-NOT-A-MODEL-KEY-7d41e9a2c0b85f36'] AssertionError: the repository's program ran: uid=1000(brett) gid=1000(brett) groups=1000(brett),1001(docker-host) ``` ## The rule, and where it is enforced A binding read from the served repository runs a broker, resolves a credential reference (`env:`, `keyring:` or a broker's), or contacts its endpoint ONLY after the operator has trusted THAT EXACT binding on THIS machine. The auth kind `none` is included, because it still sends chat content to the endpoint the file chose. - **`src/opendox/doxbench_trust.py` (new, standard library only).** - **The key** is (the repository root's resolved path, the binding id, `sha256` of the binding's canonical full record). The canonical record is `as_record()`, every field, sorted, compact, ASCII. So any edit untrusts the binding, and so does the same file under another root. - **The store** is one private file, `model-binding-trust.json`, in openDox's state directory (`OPENDOX_STATE_DIR`, through #69's `config.state_dir`). It holds no credential and is written owner-only, through an exclusive, no-follow temporary file and a rename. - **Its checks.** It is read only once it passes the checks #69's bundle makes of its own tree: no symbolic link, owned by this user, writable by no one else, and every directory above it this user's or root's, sticky where another user can write it. A store that fails a check trusts nothing, and the refusal names it. The store and its lock file are opened without waiting (`O_NONBLOCK`), so a FIFO in either place is refused by the type check on the opened descriptor rather than waited on. - **Its size.** The store writes nothing larger than it reads (`MAX_TRUST_STORE_BYTES`). A record that would outgrow it is refused before anything is replaced, so every trust already held stays held. - **Its lock.** Every record holds an exclusive lock on `model-binding-trust.lock`, beside the store, across its read, its change and its replace. So two processes recording at once keep both trusts, and neither restores a form the other replaced. The lock file gets the same link and permission checks as the store, and is then set to exactly 0600, whatever the umask. Where no lock can be taken, the record is refused by name and nothing is written. Readers take no lock: the replace is atomic. - A state directory equal to the served root, or nested under it, is refused naming `OPENDOX_STATE_DIR`, before anything is written. So is one that cannot be resolved, such as a link loop. - **A platform without the primitives the store needs** has nothing trusted, with a refusal that names the platform and the gaps (`unsupported_platform()`, following #69's form). The primitives are `os.getuid`, `O_DIRECTORY`, `O_NOFOLLOW`, `O_NONBLOCK`, `fchmod`, `mkdir` with `dir_fd`, and `fcntl.flock`. So every verdict reads untrusted rather than raising. - **A link to nothing** on the way to the store, whoever owns it, is refused by name. So is any `OSError` the store's tree raises that no check named: it becomes a `TrustStoreRefused` naming the store and the system's short word for it, in `record` and in `verdict`. - **A binding the model catalog refuses is never trusted** (`unservable_because`). Its id or its label is one the catalog cannot list, so no turn could use it. The check builds the same catalog the factory declares (`brokered_catalog`), and it runs BEFORE any policy is asked: - `verdict_for` refuses such a binding, even under a host policy that trusts every binding or with a store entry recorded earlier. The start declares the refusing port over an empty catalog and never fails. - `recorded_for` refuses such a binding, so `add`, `edit` and `trust` record nothing and write nothing. - Its refusals print no trust command, because trust cannot repair it. They name the actual remedy instead (`REMEDY_UNSERVABLE`): correct the binding with `opendox model-binding edit` (to keep the id) or `remove` then `add` (to change the id). Each of those records trust for the binding it writes. - **Every answer a policy gives is held to the binding AND the root asked about.** - `verdict_for` passes on a verdict for this root that admits exactly this binding, or an untrusted one for exactly this record at this root. Anything else becomes an untrusted verdict for THIS binding at THIS root: - a verdict for another binding, trusted or not; - another form of this one; - a verdict minted for another repository root; - a non-verdict; - a policy that raised. So every refusal names the right binding, root and command, and the per-repository key holds against a host policy too. - `recorded_for` refuses by name (`TrustNotRecorded`) a policy that declines to record, records another binding or root, or raises. It names what a policy raised, never its words. Only openDox's own store's `TrustStoreRefused` passes through as written, and only from exactly `MachineTrust`, not a host's subclass. A host policy's refusal of any class, `BindingRefused` included, is named by its class alone. - **`doxbench_install`.** The factory asks `verdict_for` about the first approved binding (`trust_gated_model_port_factory`). - Trusted: the brokered port, handed the verdict. - Untrusted: `UntrustedBindingPort`. Its catalog lists the binding `available: false`, every dispatch is refused by name, and the factory writes a notice on stderr naming the id and the command that trusts it. - A checkout with no bindings never asks the policy, so it never touches the state directory. - **In depth, `doxbench_provider`.** These all refuse a binding that no verdict covers: - every broker operation (`mint`, `hand_off_credential`, `revoke`, `list_references`), in `_broker_operation`, before the argv is built and before either runner's branch; - the built-in resolver, after its two route assertions and before its first read; - the port's `dispatch` and `catalog`. A verdict covers only the id and digest it was given for. - **`cli_model_binding`.** - `add` and `edit` record trust for the binding they write. They record it FIRST, so a store or a policy that refuses leaves nothing written and nothing printed as "trusted". - `trust <id>` (positional, no `--yes`) prints what it trusts, then records trust for exactly that record: the broker argv it would run, the endpoint, the auth kind and the credential REFERENCE, never the credential. It resolves no reference, spawns nothing and contacts nothing. - **The command every refusal, notice and `list` prints** is `opendox model-binding trust --repo-root ROOT [--bindings DOCUMENT] ID`. It is printed only from operands a POSIX shell reads back exactly (`trust_command`): - an id the catalog accepts: ASCII letters, digits, `.`, `_` and `-`, beginning with a letter or a digit, so no shell expands it and no option parser reads it as an option; - paths quoted by `shlex.quote`, and only where every character is printable. A root that is not printable is never printed: the command names it `.`, to be run from that repository's root. `--bindings` appears where the binding was read from a document given by one (`list --bindings`, or the factory's `bindings_path`). Run as printed, the command trusts exactly the binding it names. - `set-credential` is refused before its broker spawns when the binding is untrusted, and it leaves the binding untrusted. It re-trusts a TRUSTED binding after rewriting its reference. - `list` reads the bindings document ONCE, and derives everything it prints from that reading. It adds two lines per binding: - **trust:** trusted, or why not and the command that trusts it; - **console:** the one binding a console serving this repository declares, by the factory's own rule. A pending declaration is passed over, an unreadable declarations document declares nothing pending, and the console declares the first of the rest. Where `list` was given another document, the line says the console does not read it. - Every value a repository wrote is printed in a JSON string's form, by `list`, `trust`, the refusals and the notice. A newline or `\x1b[2J` in a field cannot forge or hide a line. - **`serve_workbench`.** - A turn on an untrusted binding is refused `model_unavailable` with a FIXED sentence (`UNTRUSTED_TURN_MESSAGE`) saying how to trust it. The catalog's shape is closed, so the reason travels in the refusal, the notice and `list`. Where the catalog cannot list the binding, the sentence is `UNSERVABLE_TURN_MESSAGE` instead, which names the remedy and no command that trusts (`turn_message_for`). Both fit within the released failure envelope's 500-character `message` bound. - **The console's model approval** answers `APPROVAL_NOTICE` ("becomes an available catalog entry") only where the registered trust policy admits the binding it approved. That is a governed host's approval, or a binding this machine trusts. Otherwise it answers `APPROVED_UNTRUSTED_NOTICE`, a fixed sentence saying the binding is not yet trusted and how to trust it. A binding the catalog cannot list is checked first, under any policy and without reading a store, and answers `APPROVED_UNSERVABLE_NOTICE`, which names the remedy and no command that trusts. Approval asks only a policy that is already registered, so where nothing is registered it registers nothing and reads no store. It reads the registration once, under the seam's lock (`registered_verdict_for`), so a host that unregisters meanwhile never has the default installed in its place. - **The console intake follows batch M.** Its hand-off runs a broker the served repository's `ideation/dashboard/model-declarations.yaml` names, and that broker belongs to no binding. So it asks the registered policy its OWN question, `intake_verdict_for`, which no binding's trust can answer. - openDox's strict default (`MachineTrust.intake_verdict`) always says no, refused by name (`intake_refused`, reason `INTAKE_BROKER_UNTRUSTED`, a fixed sentence). That happens before any byte of the body is read, and the body is drained unread. - A host admits the intake only through its own `intake_verdict`, an optional third callable on the seam. A policy without one admits no intake. - So a repository that declares a binding with the intake's exact fields, and gets it trusted, gains nothing. No command trusts an intake declaration's broker. - **The chat rail (`web/views/doxbench-chat.js`).** It has its own visible line, `UNTRUSTED_BINDING_REMEDY` (Python twin `doxbench_trust.UNTRUSTED_BINDING_REMEDY`), beside #74's no-model line. - It shows when the catalog has answered, is not empty, and offers nothing available, and it is announced once. - It names `"opendox model-binding list --repo-root <repository>"`, which shows whether each binding is trusted, and `"opendox model-binding trust --repo-root <repository> <id>"`. It also says that a binding already trusted, which a provider's refusal also leaves unavailable, is unavailable for the reason the console printed when its provider refused. - **`--repo-root` is in every quoted command.** `--repo-root` is required by every `model-binding` verb. Each command a fixed sentence quotes (the refused turn's, the rail's and the approval's) is parsed by the real parser in a test, with its placeholders filled in. - #74's no-model line stays hidden, because a model IS configured. - The sentence says "where it would connect" rather than naming a credential, because `test_doxbench_privacy.py` bans that word from both chat modules. - **Bindings stay committable.** The bindings document is unchanged, and no trust is ever read from it. ## Whose rule: the neutral default, and the seam `doxbench_trust` is a policy seam (`register` / `register_default` / `current` / `policy` / `unregister`), with the same window rule as `projection_seams`: the default is replaceable until it is read, a host over a host is refused, and the same registration again is a no-op. A policy carries `verdict` and `record`, and optionally `intake_verdict`. openDox's strict per-machine store is the NEUTRAL default. **Why the default is registered lazily, which departs from R1Q10 (a)'s entry-point registration** (accepted by the holder): the CONSUMERS register it, the first time one asks and only where nothing is registered yet. Those consumers are `declared_model_port_factory`, the `model-binding` verbs and the intake hand-off. The console's approval is not one of them: it asks only a policy that is already registered. So this PR adds no hunk to `cli.py` or `serve.py`, which stay out of the phase-3 single-writer order. It also fails closed: a bare process is held to the strict default too. A host's registration at process start wins. **How this relates to 4.2's seam tests.** Each seam module's tests enumerate and test that module's own seams: `tests/test_projection_seams.py`, `tests/test_doxbench_seams.py` (the doxBench validators and rail), and #77's `tests/test_column_seams.py` (`SEAMS = (cs.gate, cs.scope, cs.kickoff, cs.register)`). No test enumerates every seam of the package, so none needs this one added. `doxbench_trust.current()` refuses by naming its own seam and the registration call (`TrustPolicyNotRegistered`), as 4.2's discipline asks, and `tests/test_model_binding_trust.py` holds that. `tests/test_consumer_reach.py` derives its record over the whole package and passes with the new module, which imports no sibling. ## What T094 must register RULED by Brett Heap on openxFactory#656 comment `5970369724`, *"Governance approval (Recommended)"*: openxFactory registers its own policy, `GovernedBindingTrust`, as a sixth `seams()` entry with an undo. Under it: 1. A binding whose declaration the governance flow APPROVED is trusted. 2. A binding a repository declared that is still PENDING is refused. 3. A binding with NO declaration is trusted: the operator's own, or the console intake's new binding while its broker runs. 4. Where the declarations document cannot be read, nothing is admitted. 5. `record()` writes nothing. 6. **`intake_verdict` must answer too**, as the policy answers for a binding with no declaration. The console intake asks that question and no other, so without it the governed host's intake would be refused. With it, the intake stays as it is today, which the ruling keeps. `_GovernedHostPolicy` in `tests/test_model_binding_trust.py` is that policy as a test-local stand-in. The tests that compose it in process: - `test_a_governed_host_policy_keeps_the_governed_flow[approved|undeclared]`: the factory resolves the brokered port, and a turn reaches the listener, exactly as before this change. - `test_a_governed_host_policy_keeps_the_console_intake`: the governed intake runs its broker. - `test_a_governed_host_policy_refuses_a_pending_declaration`: the pending and unreadable cases, and `recorded_for` refusing a policy that declines. In the same checkout, the strict default refuses each of these until `trust`, and always refuses the intake. ## Evidence | Run | Tree | Result | |---|---|---| | Red: the final test file with no other change | `main` `8e377823` | 14 failed, 1 passed (`test_the_edits_cover_every_field_of_the_record`), 1 xfailed, 56 errors (`ImportError: cannot import name 'doxbench_trust'`). The two defect cases fail as quoted above. | | Red: each Copilot round's new cases, before their fixes | the code before each fix | Round 1: 8 failed. Round 2: 7 failed. Round 3: 3 failed. Round 4: the 4 new cases failed. One example: the two-process case kept only `first-binding`. | | Red: round 8's cases, before their fix | `42c98f9d` | 2 failed, 1 passed. The store's verdict waited on a FIFO, and the platform check did not name `O_NONBLOCK`. The lock file's FIFO case passes there too, because Linux opens a FIFO read-write without waiting. | | Red: round 7's cases, before their fix | `735d0c14` | 2 failed. Under umask 0777 the second record was refused ("the lock file cannot be opened"). The approval installed `MachineTrust` after a host's teardown and answered `APPROVAL_NOTICE` from its store. | | Red: round 6's cases, before their fix | `e05c475c` | 7 failed, 9 passed. Both sites the thread named told the operator to trust a binding past the catalog's bounds: the approval's availability, and a served turn's message. | | Red: round 5's cases and the self-pass's, before their fixes | `7012cda3` | 32 failed, 5 passed. Three hostile repository names made `CANARY` when `sh` ran their printed command: a newline, a terminal escape and a non-ASCII character, each followed by `$(touch CANARY)`. The factory raised `InvalidCatalogEntryError` on a trusted binding past the catalog's bounds, and `record` raised `FileNotFoundError` through a link to nothing. | | Green: the trust module, with the census, chat-configuration and deploy-shape modules | `735d0c14` (the merge's tree, run just before it was committed); the trust module alone again at `adb19f1e` | 336 passed, nothing skipped or xfailed; 135 passed at `adb19f1e` | | Whole suite (`tests` + `tests_runtime`), in a venv installed by CI's own command (`pip install --only-binary :all: -c constraints-cpython312-linux.txt -e ".[runtime,test]"`) | head `adb19f1e` | **3945 passed, 177 skipped, 0 failed.** The 177 are the database-backed runtime cases, which need CI's PostgreSQL service (`OPENDOX_TEST_DATABASE_URL`). #76 reads the same skips on its own local run. | | The 10 local reds of earlier rounds | `a6c2a844` (the 10 alone), then the head (in the whole suite above) | They were this machine's first venv: it was installed without the `test` extra, so it lacked the `local` extra's `pixeltable-pgserver`, and generate-and-open with `--local` refused ("the local install's PostgreSQL server is not installed"). In the CI-command venv, all 10 pass, with a short TMPDIR (`~/.local/state/t1`) and with the long one (`~/.local/state/t100-tmp`) alike. The socket-path length was not the cause here. | | Mutants (`mutate_t100.py`, one textual edit each) | head `adb19f1e` | **94 of 94 killed**, in one run (`mutants-run-17`). | | openxFactory's existing tests (`tests/ideation-dashboard -m "not postgres"`, in a clone named `openxFactory`, TMPDIR holding the basetemp) | openxFactory `main` `1f670bc3`, openDox code leg at `main` `8e377823`, then at T100 `7a04590d` | 4 failed, 1477 passed both times; the failure sets diff IDENTICAL (all 4 pre-existing). openxFactory injects its own `model_port_factory` and commits no bindings document, so its governed flow never reaches the gate before T094. The later rounds change only the store, how verdicts are held, and the rail's sentence. | The mutants killed: - **The digest:** each of the ten fields, each by its own hand-edit case. - **The root key:** ignored, or not resolved. - **The factory gate:** by-passed, or accepting a verdict for another binding. - **The in-depth refusals:** the broker operations, the resolver, `dispatch` and `catalog`; and a verdict admitting by id alone. - **The store:** - accepting a writable file; - following a link to its file; - skipping the link check or the tree check; - being written with mode 0666; - following a temporary-file link planted in the race window. - **The CLI:** - `add` and `edit` recording no trust, or writing before recording; - `set-credential` skipping the gate, or not re-trusting what it rewrote; - `trust` recording nothing or printing nothing; - `list`, the disclosure and the refusals printing raw values; - `list` saying nothing of trust. - **The intake and the state directory:** the intake gate; the state-directory check, skipped or blind to nesting. - **The default and the notice:** the lazy default never registered; a silent factory notice. - **The rail line:** shown beside an available model, for an empty catalog, or where a host offers intake; or never announced. - **Copilot round 1:** - recording without the cross-process lock, or never taking it; - the lock file opened through a link, or its owner and mode unchecked; - a declined record taken as trust; - a policy's failure to record escaping; - an untrusted verdict for another binding passed through; - the intake asking the binding question; - the per-machine store admitting the intake. - **Copilot rounds 2 and 3:** - the platform check skipped, or forgetting the file lock; - an unresolvable state directory escaping; - the rail line still claiming `list` says why; - a verdict for another root passed through; - a host policy's refusal text passing through, of either class; - openDox's own store's refusal sanitized too. - **Copilot round 4:** - a host's `MachineTrust` subclass passing its refusal text; - the writer outgrowing the read bound. - Round 4's "a dashed id printed where an option is read" is RETIRED with the `--` it mutated: no id the catalog accepts begins with `-`, and no command is printed for one that does. - **Copilot round 5 and the self-pass:** - a printed path not shell-quoted; - an unprintable path printed in a command; - an id the catalog refuses printed in a command; - a binding trust cannot repair told to trust; - a verdict trusting, or a record recording, a binding the catalog refuses; - servability judged by the id alone; - `list` judging a second reading; - `list`'s command, the factory's notice and the refusing port each omitting the document read; - a link to nothing gone through; - a system refusal escaping `record` or `verdict` raw; - the console line ignoring pending declarations or the document listed, or naming the last approved binding; - an approval always saying available, or registering and reading the default; - the turn message's `list` command or the rail's `trust` command dropping `--repo-root`. - **Copilot round 6:** - an approval of a binding the catalog refuses saying to trust it; - a turn on such a binding saying to trust it; - a refused turn naming no cause. - **Copilot round 7:** - the lock file keeping the umask's mode; - an approval reading the seam twice; - the registered verdict registering the default. - **Copilot round 8:** - the store waiting on a FIFO in its place; - the platform check forgetting the nonblocking open. The F16.1 batch M cases each serve a fresh `git init` with a fresh `OPENDOX_STATE_DIR`. They run over three bindings in turn: - a broker that writes a marker file; - an `env:` reference whose environment records every name read; - a `keyring:` reference whose stand-in backend records every lookup. The listener records every request. One test per case of the block. **`set-credential` on an `env:` or `keyring:` binding** is refused by the refusal it already had, "names no broker", which also comes before any read or spawn. Naming `trust` there would point at a command that cannot make the verb work. ## Review Every Copilot finding was accepted and fixed with a case that failed first and its mutant, then answered on its thread and resolved: - **Round 1, at `1ef4c71d`, fixed in `7a04590d`:** - `r4173513738`: a policy that declines to record; - `r4173513761`: the lock across processes; - `r4173513782`: the intake's own question; - `r4173513795`: a verdict for another binding. - **Round 2, at `7a04590d`, fixed in `e4b145d1`:** - `r4173876800`: a platform without the store's primitives; - `r4173876823`: an unresolvable state directory; - `r4173876849`: what the rail line says `list` shows. - **Round 3, at `8270dffc`, fixed in `e4b145d1`:** - `r4174310794`: a verdict for another root; - review `5402101086`'s "previously missed" item: a host policy's refusal text. - **Round 4, at `cb691b18`, fixed in `24a1c25e`:** - `r4174632006`: only the exact `MachineTrust` passes its refusal; - `r4174632060`: the printed trust command runs as printed; - `r4174632086`: the write bound. - **Round 5, at `7012cda3`, fixed in `db77c4ea`:** - `r4174783197`: a printed command a shell reads back exactly; - `r4174783250`: `list` reads its bindings once; - `r4174783280`: a binding the catalog refuses is never trusted, and never fails the start; - `r4174783301`: a link to nothing, and any unnamed `OSError`, refused by name. - **Round 6, at `e05c475c`, fixed in `a6c2a844`:** - `r4175203889`: a binding the catalog cannot list is told its remedy, at the approval and in a refused turn, and never to trust it. - **Round 7, at `735d0c14`, fixed in `42c98f9d`:** - `r4177946237`: the lock file is 0600 whatever the umask; - `r4177946288`: the approval reads the trust seam once. - **Round 8, at `42c98f9d`, fixed in `adb19f1e`:** - `r4178064601`: the store and its lock file are opened without waiting on a FIFO. **The adversarial self-pass, in the same commit.** It covered two things: - **Every command printed for a human to paste.** That is each refusal, the notice and `list`, plus each command a fixed sentence quotes. Hostile values in every interpolated operand were run through `sh`, `bash` and `zsh`. - **The trust-state machine,** for whether what is printed, what is stored and what is enforced agree: pending, approved, undeclared, unreadable declarations, and a stale record. It found three gaps, each now fixed with its case and mutant: - `list` did not say which binding a console declares; - an approval said "available" for a binding the strict default still refuses; - the fixed sentences quoted `model-binding` commands without the required `--repo-root`. **SonarCloud.** The quality gate's one failure was `python:S5332`: the trust disclosure spelled a plain-HTTP URL scheme, and now says "plain HTTP". The two functions over the cognitive-complexity bound (`_unsafe_because`, `_refuse_an_unsafe_tree`) are split into named parts, with the same rules. The gate passes from `7a04590d` on. ## CI's triple `EXPECT_SKIPPED` is 11, `main`'s own value. It moved to 14 for three strict-xfail cases, each waiting on a draft and naming it: - the store's default home (#69); - the rail's trust line (#74); - a served turn that reaches its model step standalone (#77). It stepped back by one, with its reason in the workflow, as each draft reached this branch (`1ef4c71d`, `32646871`, `78d1e904`). All three cases now run and pass. The floors are not moved. **The web census's class-A total is re-derived from the merged tree at every merge of `main`.** It is 18526 at the head: `main`'s 18486 plus this change's 40 lines in `views/doxbench-chat.js` (1890 to 1930). Round 5 changed that file within one line, so its count holds, and #76 touched no web file. **The floors** are #76's re-pin (3977 / 3966, T082). T100 moves neither, since it only adds cases. The merge kept both reasons for `EXPECT_SKIPPED` holding at 11, T082's and T100's. #84 (T104) also moves `EXPECT_SKIPPED` and class A. Whichever of the two lands second re-derives both values from its own merged tree. ## #77, and other notes - **#77 refuses the console intake standalone** when no gate-record writer is registered (`5961364221` item 1). So this file's intake cases use a stand-in host that registers a host gate at `opendox.column_seams.gate`, as #77's own tests do. - **#77's host fixture** runs `model-binding add` in a CHILD with a private `OPENDOX_STATE_DIR`. The parent's factory reads its own store, so a turn there still reads the binding untrusted until that store trusts it. - **#69's tree checks are DUPLICATED here, not imported.** `runtime/bundle.py`'s helpers take a different signature, raise `BundleRefused` with the bundle's wording, and the module imports heavier modules. They are kept in step by rule, and the tests hold both. ## Gates - `pyflakes` is clean over the changed modules. - `tests/test_provider_boundary.py` passes. No module outside `doxbench_provider` spells its banned needles. - No closing keyword appears in any commit message on this branch or in this body. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…(plan 034) (#84) Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Plan 034 (`specs/034-opendox-standalone-operation/`), phase 3: - **T104, the console token travels in the opened URL, not `/capabilities`.** RULED on openxFactory#656 in [`5963851934`](https://github.com/opensoft/openxFactory/issues/656#issuecomment-5963851934) (Brett, 2026-10-03, *"Token via the opened URL (Recommended)"*). Its plan entry landed with openxFactory#1220 (`ec9308c8`). - **From adversarial review 2's M5 (pre-existing).** A standalone openDox served its per-serve console token from `/capabilities` to any loopback caller, other OS users on the same machine included. Nothing checks which local user connects. The token lets a page edit documents and run chat turns that spend the operator's model credential. - **The holder's ruling on openxFactory#1220's review** (Copilot `r4171166321`): the private copy refuses an `OPENDOX_STATE_DIR` that is, or lies inside, a served root. This mirrors T100's served-repository boundary. - **After:** T103 (#80, landed as `390e2c28`) and T102 (#81, landed as `0116293a`; its follow-on #85 landed as `c4b55cc4`). `serve.py`'s single-writer order is T084 (#77) → T103 (#80) → T104. Every After line is met. - **Base `main`.** The holder retargeted #84 when #80 landed, and the diff is still T104 alone. It was built on #77's `3387293e` and merged forward as its base moved, never rebased: - #80's `d0f1efcb` at `6db50b03`; - #80's final pre-squash head `b9025b6c` at `c1e7d8d8`; - main `390e2c28`, #80's squash, at `60bace00`. That merge's tree came from `git merge-tree --write-tree --merge-base b9025b6c`, with both parents recorded. The squash has `b9025b6c`'s tree, so the merge changed no file; - main `0116293a` (#81) at `d4b99436`, main `c4b55cc4` (#85) at `182cac76`, main `ca9e1bd5` (#76, T082) at `ddb26c34`, and main `38d3350e` (#82, T100) at `021944a6`. All are plain merges, because this branch held none of those PRs' own commits. Each time, the census's class-A total was re-derived from the rows of the merged tree, now 18621: #82 moved `doxbench-chat.js` 1890 → 1930, and T104 moves `notebook.js` 109 → 204. `validate.yml` merged cleanly each time; T104 adds no skip, so `EXPECT_SKIPPED` stays 11. The `ca9e1bd5` merge applies F2 from the holder's T096 dry run: #76's new postures fixture read a standalone child's token from `/capabilities`, and now reads the private copy (`child.console_token(base[1])`). - **Fix round 1** (`cb899546`, Copilot `r4173806506`, `r4173806552`, `r4173806590`, `r4173806621`): every served root is in the boundary, removal is exact under a race, a plain `kill` removes the copy, and the shutdown cases look before cleanup. See "Fix round 1" below. - **Fix round 2** (`c979747a`, Copilot `r4173889265`, `r4173889294`): the copy is judged by its own path, and no static link serves it. See "Fix round 2" below. - **The reverse boundary** (`36ff6103`): the holder's ruling on batch N's Copilot review, openxFactory#1222 [`r4174345203`](https://github.com/opensoft/openxFactory/pull/1222#discussion_r4174345203). The state directory and every served root may not overlap in EITHER direction. See "The reverse boundary" below. - **Fix round 3** (`219d7cf9`, Copilot `r4174674625`, `r4174674702`): a directory's index page is judged, and a FIFO never blocks a read. See "Fix round 3" below. - **12.4a, clause by clause** (`a13857ad`): openxFactory#1222 (T007 batch N) landed as `bdd0f586`, and its amended 12.4a is the normative text for T104. Every clause is checked and its gaps are closed; the opener file's lifecycle is self-reviewed in the same commit. See "12.4a, clause by clause" below. - **One walk of the state directory** (fix round 4, `14285fbb`, Copilot `r4174785933`): every directory and link the walk passes through is judged, and the write is anchored to that walk. See "One walk of the state directory" below. - **Fix round 5** (`9f328892`, Copilot `r4175213798`, `r4175213842`, `r4175213864`, and `r4177975845` at `ddb26c34`): the snapshot files are served roots, publication and removal take one lock, and a stop is held through publication. See "Fix round 5" below. - **Fix round 6** (`9dcc9bb5`, Copilot `r4177975898`): an operating-system error during the walk is a refusal by name, for the writer and the reader. See "Fix round 6" below. - **Fix round 7** (`fb8a1cc4`, Copilot `r4178041022`): Ctrl-C is held like SIGTERM while a copy is written or removed. See "Fix round 7" below. - **Fix round 8** (`66cffdb8`, Copilot `r4178133814`, `r4178133842`): a running console's copy is reserved for its server's life, and every read that serves a file without the console check judges the file it opened. See "Fix round 8" below. - **The holder's adversarial review** (fix round 9, `45958bf3`; findings B1-B10 on `fb8a1cc4` and round 8): every standalone plane keeps the boundary, token or not; a platform without the POSIX primitives is refused by name; a browser that cannot open the copy is told how to move it (B3, ruled by Brett); a second spelling of a directory is judged by its identity; only the first stop is raised; `--no-serve` publishes nothing; and a publication sweeps the copies of consoles that died. See "The adversarial review" below. - **Fix round 10** (`af2a2efb`, Copilot `r4179091592`, `r4179091624`): the tokenless plane's guard walks the state directory once, as the writer does, and refuses an unsupported platform first. See "Fix round 10" below. - **Fix round 11** (`ed6e4769`, Copilot `r4179239380`, `r4179239411`, `r4179239424`, and a finding its review at `af2a2efb` lists as previously missed): a copy is known by what it holds, in any state directory and mid-removal; a check that cannot be made denies; an entry's in-memory payload comes before its guarded file. See "Fix round 11" below. - **Fix round 12** (`a2e36652`, Copilot `r4179793524`): a copy is written from a marker, so a part-written or growing copy is known by its first bytes; every reader judges the bytes it sends. See "Fix round 12" below. - **Fix round 13** (`c5fcdfa4`, Copilot `r4180089809`): the console page's URL is judged as a browser reads it: an exact loopback authority, and no backslash or control character. See "Fix round 13" below. Claimed on openxFactory#656 in [`5963979413`](https://github.com/opensoft/openxFactory/issues/656#issuecomment-5963979413). The holder posts READY, and the landers merge. ## What changes: the delivery only **Standalone means openDox's own default profile.** A plane is standalone when the profile `build_server` builds from IS `opendox.default_profile`, the one an entry point registers where no host has (`console_access.delivery_for`). - On a standalone plane, the token is minted exactly as before, and `/capabilities` no longer carries it. - A HOST's plane (openxFactory's `opendox_host.register_openxfactory()`, and this suite's own `_SuiteProfile`) keeps the `/capabilities` delivery, unchanged. See "The governed path" below. **`generate-and-open` and `python -m opendox.serve` write a private copy** at `<OPENDOX_STATE_DIR>/console/<port>.html`: - it is an HTML page that forwards to `http://127.0.0.1:<port>/index.html#console_token=<token>`. The token is in the **fragment**, never the query string, so it never reaches a request line, a server log or a `Referer`; - it begins with a marker comment (`COPY_MARKER`), before any byte of the token, so any part of a copy that holds a token byte is known for a copy (fix round 12); - it embeds the same record as JSON, escaped so that no value can end its `<script>` element; - **the browser is handed the copy's `file://` path, never the tokenized URL.** A URL given to `webbrowser.open` sits on a command line (`xdg-open`, the browser), and every user of the machine can read `/proc/<pid>/cmdline`. This is Jupyter's own redirect file, for the same reason; - the start prints the copy's path (` console file://…`) and **never the token**, with or without `--no-open`. To re-open the page, open that file again. Beside the path, one line with no token tells a user whose browser cannot open that file how to move the state directory (B3, ruled; see "Known limits"). There is no new verb, so the governed `--help` golden is unchanged; - the copy is removed when the server stops, BEFORE the listening socket closes. Only the file this process wrote is removed, so a later serve on the same port keeps its own. Another serve's copy is put back by a hard link, or by a rename where links fail. Every writer and remover holds the console directory's lock, so the put-back can never overwrite a newer copy. SIGTERM and SIGHUP are read as Ctrl-C from before the copy is written to after it is removed. So a plain `kill` or a closed terminal removes it too. A stop that arrives while the copy is written or removed, Ctrl-C included, is held until that is done, and only the first stop is raised (fix round 9). A SIGHUP the process was started ignoring (`nohup`), an ignored SIGINT, and a host's own handler are left as they were; - **a copy is reserved for its server's life** (fix round 8): the writer holds an exclusive `flock` on the file it wrote, on its own descriptor, until the copy is removed. A second console on the same port number and state directory (one on 127.0.0.1, one on ::1) is refused by name and never replaces it. A copy whose console died holds no lock, and the next publication sweeps it, whatever its port (fix round 9); - `--no-serve` writes no copy, opens none and prints no console line, since it closes the server before anything could use one (fix round 9); - if no safe copy can be written, the start is refused by name (`generate-and-open refused: …` / `serve refused: …`, exit 1), and the listening socket is closed. A platform without the POSIX primitives the copy rests on (Windows) is refused by name as well (fix round 9). An operating-system refusal, such as a directory this user cannot make or a full disk, is refused by name too. A write that fails part way leaves no partial file, and a copy whose read-back fails is removed. A console nobody can be handed is not served as if it could be. **The copy is checked as #69's bundle checks its tree, and by T100's trust-file rules.** `src/opendox/console_access.py` copies the rules (it does not import `runtime/bundle.py`'s private helpers): - the state directory and `console/` must be real directories, owned by this user and writable by no one else. `console/` must also be exactly 0700: its permission bits are judged, so a setgid bit inherited from a parent is accepted; - the state directory is resolved ONCE, by one walk. Every directory the walk passes through must be this user's or root's, and sticky if others can write it, including a directory reached only through a link's target. Every symbolic link it follows must be this user's or root's. The served-root boundary, the tree rules, the write and the read all work on the walked path, never on the configured one again; - a missing directory is made relative to its parent's descriptor, born 0700 under a 077 umask, and opened with `O_NOFOLLOW` before anything is made beneath it; - the file is created with `O_CREAT|O_EXCL|O_NOFOLLOW`, `fchmod`ed to 0600 on its descriptor, fsynced, then renamed into place; - if a name already at the target is anything other than this user's own regular file of mode 0600 with one link, it is **refused by name and never followed or replaced**, and the start is refused. That covers a link, a dangling link, a directory, a FIFO, another user's file, a hard link and a loosened copy; - **the served-root boundary** (the #1220 ruling, made two-way by the reverse ruling): the state directory and every root the plane serves may not overlap in either direction. A state directory that IS a served root, lies inside one, or HOLDS one is refused by name before anything is created (`OPENDOX_STATE_DIR (…) lies inside the served repository (…)`, or `… holds …, a root this plane serves`). The served roots (fix round 1) are: - the checkout; - the static bundle's `--web-dir`; - each declared `--source-root`; - each registry entry's root; - on a loopback plane, the sessions container; - the snapshot files `/snapshot.json` reads directly: the configured snapshot, each registered entry's, and, on a loopback plane, the session snapshots' container (fix round 5). Paths are compared after resolving, so a link from outside into a served root counts, and so does a served root named through a link into the state directory. Every directory on either path is also compared by its identity, `(st_dev, st_ino)`, so a second spelling of one directory, as a case-insensitive filesystem allows, is the same directory (fix round 9); - **every standalone plane keeps that boundary, token or not** (fix round 9). A plane with no git identity mints no token and writes no copy, but it shares the state directory with the planes that do. It still refuses a state directory that overlaps its served roots, and still marks the copies' directory private; - fix round 2 judged the copy's own path, so a served root that holds `console/` refused the write. The reverse rule covers that case and every other served root inside the state directory. That separate check is gone, and its case still passes; - the static handler never serves a copy: `publish` marks the private-copy directory on the server, and `DashboardHandler.send_head` answers 404 for any static target whose resolved path is that directory or inside it (fix round 2), by name or by the directory's identity (fix round 9). For a directory request, the index page the handler would serve (`index.html`, then `index.htm`) is judged too (fix round 3). The file it would serve is judged by its own identity before it is opened, and the file the handler opened is judged again, so a hard link or a link swapped in between is never sent (fix round 8). The body sent is bounded by the length judged, and its first bytes are judged as they are read (fix round 12). Links in `--web-dir` are still followed, since a governed host's composed web root is made of them; - `/source`, `/snapshot.json` and each registered entry's snapshot read through `serve.read_unless_private`, which judges the file it opened by its identity: a root re-pointed at the state directory after publication, or a hard link to the copy, is a 404 (fix round 8). Every such check also judges the opened file by what it holds, so a copy in another plane's state directory, or one removed mid-read, is refused, and a check that cannot be made denies (fix round 11). The bytes read are judged too, so a copy part-written or growing in another state directory is refused (fix round 12); - a read (`read_private_copy`, which the tests and T095's harness use) asks all of it again. The file is opened without blocking, so a FIFO is refused at once (fix round 3). It is checked on its descriptor: a regular file, this user's, exactly 0600, one link. **The page** (`web/views/notebook.js`, its only web file): - it takes `#console_token=` from `location.hash` at import, before the shell's first fetch; - it keeps the token in `sessionStorage` for this tab, or in memory where storage is blocked; - it strips the fragment with `history.replaceState(state, "", pathname + search)`, and a malformed token is stripped too; - `probeCapabilities()` fills the token into a payload that carries none, so every view keeps reading `caps.console_token` unchanged. A host's published token always wins. The query string is never read; - `app.js`, `edit.js` and the staging workbench's JS are untouched (T102 edits the workbench). `notebook.js` stays import-free, so its census row is re-measured (109 → 204) and class A's total is re-derived. **Every route that requires the token still requires it.** `_not_the_human_console`, the catalog, the thread read, the chat turn, the abstract, `/actions/edit` and the gate verbs are unchanged. ## Fix round 1 (`cb899546`) Copilot's review at `6db50b03`, four threads: - **`r4173806506`, served roots.** `build_server` reports every root the plane serves files from: the checkout; the static bundle's `--web-dir`, whose handler would serve a copy under it to anyone, 0600 notwithstanding, since the server reads it as its owner; each declared source root; each registry entry's root, which includes the bootstrapped session worktrees; and on a loopback plane the sessions container, where every later session worktree is made. Cases: a state directory under the static bundle and under the sessions container is refused and nothing is written; the reported set is asserted. - **`r4173806552`, a removal race.** `remove_private_copy` takes the name with an atomic rename to a name only this process uses, then judges what it took. Its own file is removed; anything else is linked back under the name, never over a still newer copy. Both entry points also remove the copy BEFORE closing the listening socket. Case: a replacement written at the instant of removal survives whole. - **`r4173806590`, SIGTERM.** While a copy exists, `python -m opendox.serve` and `generate-and-open` (local and hosted) read SIGTERM as Ctrl-C (`console_access.terminate_as_interrupt`) and restore the previous handler afterwards. A plane that wrote no copy keeps SIGTERM's default, so the governed and hosted images are unchanged. Cases: the context manager; a plain kill of each of the three entry points exits 0 with the copy gone. - **`r4173806621`, the shutdown assertions.** They now signal and wait (`_stop`), assert while the state directory still exists, and leave cleanup to the case's `finally`. ## Fix round 2 (`c979747a`) Copilot's review at `cb899546`, two threads. Each case failed first at `cb899546`, then passed: - **`r4173889265`, a served root holding `console/`.** The boundary judged the state directory only, so `--web-dir` equal to `<state>/console` (state directory outside every served root) let `GET /<port>.html` serve the copy. The copy's own resolved path is now judged as well: a served root that holds it refuses the write by name. Case: `test_a_served_root_equal_to_the_console_directory_is_refused` (it failed first: DID NOT RAISE). - **`r4173889294`, an outward link in the bundle.** The static handler follows links inside `--web-dir`, so `web/state-alias -> <state>` served the copy. Links are not refused wholesale: openxFactory's `scripts/ideation-dashboard-serve.py` composes its web root from links out of the bundle (`_composed_web_root`), and confining to the resolved root would 404 the governed bundle. Instead `publish` marks the private-copy directory (`httpd.private_roots`), and `DashboardHandler.send_head` answers 404 for any static target whose resolved path is that directory or inside it, for GET and HEAD, files and listings, every port's copy included. A server with no copy marks nothing. Case: `test_a_static_link_out_of_the_bundle_never_serves_a_private_copy` (it failed first: GET answered 200). ## The reverse boundary (`36ff6103`) The holder's ruling, from batch N's Copilot review ([`r4174345203`](https://github.com/opensoft/openxFactory/pull/1222#discussion_r4174345203)). `_refuse_a_served_state_dir` checked one direction only: a state directory in a served root. A served root INSIDE the state directory would let the plane serve what the state directory keeps, the copy among it. Such a root could be `<state>/console` itself, the bundled PostgreSQL's `postgres/run`, or any deeper path. Fix round 2's copy-path check caught only the root that holds `console/`. - **The fix.** The two may not overlap in either direction. A state directory equal to a served root, inside one, or holding one is refused by name before any write. - **Case:** `test_a_served_root_inside_the_state_directory_is_refused`, for `console`, `postgres/run`, `anything/else/deep` and `.` (the state directory itself). It failed first (DID NOT RAISE). At `cb899546`, 3 cases failed: `console`, `postgres/run` and `anything/else/deep`. At `c979747a`, 2 failed, because fix round 2 already covered `console`. - **Pin:** `test_a_source_link_into_the_state_directory_never_serves_the_copy`. A link inside a declared source root that points at the state directory gets 404 from `/source`, which never follows a link out of its root. It passed before the change. It is pinned here and held by mutant M13. ## Fix round 3 (`219d7cf9`) Copilot's review at `60bace00`, two threads. Each case failed first at `60bace00`: - **`r4174674625`, a directory's index page.** For `/sub/`, the stdlib handler serves the first of `index_pages` that is a file, and `send_head` judged only the directory. So `web/sub/index.html`, linked to a copy, was served at `/sub/` while `/sub/index.html` answered 404. The index page the handler would pick is judged too. The case is `test_a_directory_index_linked_to_a_private_copy_is_never_served`, run for `index.html` and `index.htm`. It covers GET and HEAD of `/sub/`, `/sub/<index>` and `/sub/?x=1`, and checks that the bare `/sub` redirect carries nothing. A directory whose index page is the bundle's own still answers 200. Before the fix, GET `/sub/` answered 200. - **`r4174674702`, a FIFO.** `read_private_copy`'s read-only `open` of a FIFO with no writer blocked forever, before the descriptor check could refuse it. It opens with `O_NONBLOCK` now. The case is `test_a_fifo_at_the_copy_is_refused_without_blocking`, which runs the read and the write on threads with a bounded join, so a failure fails and never hangs. Before the fix, "the read blocked on a FIFO". ## 12.4a, clause by clause (`a13857ad`) openxFactory#1222 (T007 batch N) landed as `bdd0f586`, and its amended 12.4a is the normative text for T104. Every clause was checked against `d4b99436`. Batch N's gaps (c), the non-blocking reader, and (d), the automatic index file, closed in fix round 3. This commit closes the rest. Each case failed first at `d4b99436` unless it is marked as a pin. - **(a) "Replaced only when it is this user's own regular file of mode 0600 with one link."** The writer checked the type, the owner and the link count, but not the mode, so a loosened own copy was replaced. It now applies the reader's own rule, so a loosened copy is refused by name and left as it is. This also answers Copilot's `r4174785965` at `d4b99436`. - **"In a directory of mode 0700."** A `console/` loosened after it was made (0755, 0750, 0711) was accepted wherever no one else could write it. The writer and the reader now refuse it by name. A setgid bit inherited from a parent is accepted (a pin). - **(b) The writer's and the entry points' regressions.** One is another user's file at the name (a pin). The other runs through `generate-and-open` and through `python -m opendox.serve`: each of a symbolic link, a directory, a FIFO, a hard-linked copy and a loosened copy refuses the START by name. That means exit 1, nothing printed that serves, no browser, the planted thing untouched and the socket closed. The loosened copy failed first; the other kinds are pins. - **(e) A served root named through a symbolic link** that leads to the state directory or into it (`console`, `postgres/run`, the directory itself) is judged where it leads. Through the entry point, the case is a `--web-dir` link to `<state>/console` (pins, held by mutant M24). **The opener file's lifecycle, self-reviewed** (write, replace, read, remove at stop, refuse before writing). Each finding below has a case that failed first at `d4b99436`, and a mutant: - an operating-system refusal on the way, such as a parent that will not let this user make the state directory or a full disk, escaped as a raw `OSError` with a traceback and no refusal by name. It is now a `ConsoleAccessRefused` naming the copy, through both entry points; - a write that fails part way removes its temporary file; - a copy whose read-back fails is removed with the refusal; - removal put another serve's copy back by a hard link only. Where links fail (EPERM: a filesystem without them, or a directory), that copy was deleted. It is now renamed back where the name is free; - SIGHUP, which a closed terminal sends, ended the process with the copy left behind. While a copy exists it is read as Ctrl-C, as SIGTERM is, unless the process was started ignoring it (`nohup`); - the signal handling covered only the serve loop. A SIGTERM while the browser opener ran, which can take seconds, took SIGTERM's default action on a hosted standalone plane and left the copy behind. It now covers the whole window from the write to the stop, in both entry points. ## One walk of the state directory (fix round 4, `14285fbb`) Copilot's review at `d4b99436`, `r4174785933`. Copilot's probe reproduced here. With `OPENDOX_STATE_DIR=alias/state`, `alias -> shared/hop` and `hop -> private`, the tree rules judged the configured path's components and the directories above the resolved path. `shared`, reached only through a link's target, was neither, so at mode 0777 and not sticky it went unjudged. The write also walked the configured path again after the served-root check. So a `hop` re-pointed in between put the token's copy in a served root, at `served/state/console/8080.html`, where `/source` serves it. `_walked` resolves the state directory once, component by component as the kernel walks it. Every directory it passes through is judged by the rule for directories above the state directory, and every link it follows by the rule for a link. The served-root boundary, the tree rules, the write and the read all work on the walked path. Both cases failed first at `182cac76`: - `test_a_directory_passed_through_by_an_intermediate_link_is_judged`: Copilot's layout with `shared` at 0777. The write and the read are both refused by name. Before the fix: DID NOT RAISE; - `test_a_link_swapped_after_the_checks_never_redirects_the_write`: `hop` is swapped to a served root right after the served-root check. The copy lands where the checks saw the state directory, and the served root gains nothing. Before the fix, the copy landed in the served root. ## Fix round 5 (`9f328892`) Copilot's review at `182cac76`, three threads. Each case failed first at `ddb26c34`: - **`r4175213798`, the snapshot files.** `/snapshot.json` reads its file directly, not through the static handler. So a `--snapshot` named at an earlier copy, `<state>/console/<port>.html`, would have been replaced by the new copy and served to anyone. The served roots now hold the configured snapshot, each registered entry's snapshot, and, on a loopback plane, the session snapshots' container. The reverse boundary therefore refuses that start by name, through a link as well. Cases: - `test_a_snapshot_inside_the_state_directory_refuses_the_start`, which before the fix reported "the server served"; - `test_the_plane_reports_its_snapshot_files_as_served_roots`. - **`r4175213842`, the rename-back race.** Where a hard link fails, another serve's copy is renamed back where the name is free. A newer copy published between that check and the rename was overwritten. Every writer and remover of `console/` now takes the directory's exclusive `flock`, so publication and removal are serialized. The case is `test_a_copy_published_during_a_rename_back_is_never_overwritten`: a third copy is published at exactly that moment, waits, and stands. Before the fix, "an older copy overwrote the newest". - **`r4175213864`, a stop during publication.** SIGTERM was read as Ctrl-C only after `publish()` returned. Both entry points now install the handler BEFORE publication, on any plane that writes a copy, and keep it through removal. A stop that arrives while the copy is written or removed is held (`deferred_termination`): publication raises it once the copy is in hand, and removal lets it go. Cases: - `test_a_stop_during_publication_removes_the_copy`, for both entry points; - `test_a_stop_during_removal_lets_the_removal_finish`; - `test_deferred_termination_holds_a_stop_until_the_block_ends`. Copilot's review at `ddb26c34` raised the same stop for `generate-and-open` (`r4177975845`), and `9f328892` answers it. ## Fix round 6 (`9dcc9bb5`) Copilot's review at `ddb26c34`, `r4177975898`. The walk and the served-root check ran outside the writer's conversion of `OSError`. So an overlong state-path component (ENAMETOOLONG) or an unsearchable parent (EACCES) escaped as a raw `OSError`, and both entry points ended in a traceback instead of a named refusal. Both are inside the conversion now, and the reader converts the same way. The cases failed first at `9f328892`: - `test_a_state_path_the_walk_cannot_take_is_refused_by_name`, for both kinds of path, through the writer and the reader; - `test_a_state_path_the_walk_cannot_take_refuses_the_start`, for both kinds through `serve` and through `generate-and-open`. Each exits 1 with the refusal named, prints nothing that serves, and closes the socket. ## Fix round 7 (`fb8a1cc4`) Copilot's review at `9f328892`, `r4178041022`. SIGINT kept Python's immediate handler, so `deferred_termination` never held it. A Ctrl-C just after publication's rename raised before the caller held the copy, and the copy was left behind. A second Ctrl-C just after a removal's take left a `.removing-*` file holding the token. `terminate_as_interrupt` now takes SIGINT too, still raised as a `KeyboardInterrupt`, but only where it has Python's own handler; an ignored SIGINT, or a host's own handler, is left as it was. The cases failed first at `9dcc9bb5`. Each one checks for the handler before it sends SIGINT, so a raw interrupt never aborts the test session: - `test_ctrl_c_just_after_the_copys_rename_leaves_no_copy`, for both entry points; - `test_a_second_ctrl_c_after_the_removal_rename_leaves_nothing`; - `test_terminate_as_interrupt_takes_ctrl_c_only_from_its_default`. ## Fix round 8 (`66cffdb8`) Copilot's review at `fb8a1cc4`, two threads. Each case failed first at `fb8a1cc4`: - **`r4178133814`, two consoles on one port number.** The copy's name is per port, so a console on 127.0.0.1 and one on ::1, sharing a state directory, replaced each other's copy, and the first console's printed path opened the second. The writer now takes an exclusive `flock` on the file it wrote, before the rename, on its own descriptor (`_Reservation`, held in `PrivateCopy.reservation`), and keeps it until the copy is removed. A later publication on that port asks for the lock without waiting. A held lock is a running console's, refused by name (`… belongs to a console that is still running (pid N)`), and its copy is left as it was. A free lock is a stale copy's, and it is replaced. Cases: - `test_two_consoles_on_one_port_number_never_share_a_copy`, with real IPv4 and IPv6 planes (failed first: DID NOT RAISE); - `test_a_running_consoles_copy_is_never_replaced` (failed first: DID NOT RAISE); - `test_a_copy_whose_console_died_is_replaced`, a pin: a subprocess writes a copy and exits, and the kernel releases its lock. - **`r4178133842`, a root retargeted after publication.** `/source` resolves a declared root again on every request, so a root re-pointed at the state directory after publication served the copy. Every read that serves a file to any caller without the console check now judges the file it OPENED, by `(st_dev, st_ino)`, against every name in the copies' directory (`console_access.is_private_file`). `/source` and `/snapshot.json` read through `serve.read_unless_private`. The static handler judges the file it would serve before the stdlib opens it, and judges what the stdlib opened; a file swapped in between is closed unsent and the connection closed. The identity also stops a hard link to the copy, which a path cannot tell apart. Cases, each answered 200 with the copy before the fix: - `test_a_source_root_retargeted_after_publication_never_serves_the_copy`, Copilot's layout; - `test_a_snapshot_retargeted_after_publication_never_serves_the_copy`; - `test_a_hard_link_to_the_copy_is_never_served`, for the static bundle and the served checkout; - `test_the_static_backstop_never_sends_a_copy_swapped_in_after_the_check`, which blinds the first check and reads the raw response: the copy's bytes are never sent. ## The adversarial review (fix round 9, `45958bf3`) The holder had an independent adversarial review of `fb8a1cc4` and of round 8 run ahead of Copilot: 1 high, 3 medium and 6 low findings. It confirmed that round 8 answers both of Copilot's threads, and it found what follows. The reviewer's own cases are kept as written, under their ids (`test_b1_…`, `test_b2_…`, `test_b3_…`, `test_b6a_…` to `test_b6c_…`). The cases that pin B1, B2, B3, B4, B5, B8 and B9 failed first at the merged pre-fix tree. - **B1 (high), a tokenless sibling plane.** A standalone plane that minted no token (no git identity, so no session verbs) wrote no copy, so it asked no boundary and marked no private root. A root of its that held the shared state directory served a sibling plane's copy, token and all, to any local caller. The delivery is now the plane's, token or not (`build_server`), and `publish` on every standalone plane asks the boundary and marks the copies' directory private (`console_access.guard_private_roots`). Only the writing still needs a token. Cases: the reviewer's two real planes, where the tokenless plane now refuses its start by name; the same refusal in the process; and a tokenless plane whose `--web-dir` links into the state directory and whose checkout holds a hard link to the sibling's copy, which answers 404 for the copy, the listing and `/source`. - **B2, a platform without the POSIX primitives.** On Windows the standalone start ended in an `AttributeError` traceback. `console_access.unsupported_platform()` names what is missing, and the writer and the reader refuse by name before anything else, as `bundle.unsupported_platform()` does for #69's bundle (holder's ruling). Cases: the reviewer's child with `os.getuid`, `O_NOFOLLOW` and `O_DIRECTORY` removed, which now exits 1 with `serve refused: …`; and the writer and the reader without `O_NOFOLLOW`, and without calls relative to a directory's descriptor. - **B3, a browser that cannot open the copy.** Ubuntu's default snap browser cannot read a file under a hidden directory such as `~/.local/state`, and a Windows browser under WSL may not open a Linux path. There was no way past it, because the token is never printed. RULED by Brett (2026-10-04, *"Hint line, accepted limit (Recommended)"*): beside the copy's path, the start prints ONE line with no token, saying to set `OPENDOX_STATE_DIR` to a folder that is not hidden and start again (`console_access.UNOPENABLE_HINT`). The case runs both entry points, finds the line once, right after the copy's path, and finds the token in no line printed. The README's side is openDox#17's (T076). See "Known limits" below. - **B4, a second spelling.** The served-root overlap and the static handler's guard compared spellings. On a case-insensitive filesystem (macOS's default), `<root>/STATE` is `<root>/state` and `/state-alias/CONSOLE/` lists `console/`, while resolving a path keeps the case it was given. Both checks now also compare the directories' `(st_dev, st_ino)`, the copies' directory's own included (`within_private_roots`). Linux cannot spell one directory two ways without root, so the cases simulate a case-insensitive filesystem by telling `os.stat` and `os.listdir` that the second spelling is the first: the boundary for the state directory, a root inside it and a root holding it, and the static listing. The reviewer's macOS reading is derived from the code, not observed on a Mac, and the same holds here. - **B5, a second stop.** A double Ctrl-C, or a SIGTERM and then a closing terminal's SIGHUP, could land its second signal after the first had unwound the serve loop and before the cleanup's hold. That second interrupt escaped the `finally` and left the copy. The first stop is now latched, and every later one is only recorded. Cases: the reviewer's child, which delivers the second stop at exactly that point and now exits 0 with no copy and no traceback; and the latch in the process. - **B6, three rules no case pinned.** The walk's link-owner check, the reader's re-judging of the tree, and the `fchmod` under a umask that strips owner write each had a mutant the suite let live. The reviewer's three cases kill them (M46, M47, M48). - **B7, browser history.** Accepted by the holder as a limit within the ruled design. See "Known limits" below. - **B8, `--no-serve`.** It opened a copy for a server it then closed, and deleted the copy on return. It now publishes, opens and prints no copy. The page's URL is still printed and opened, as before T104, and carries no token. The cases that read a copy now serve once and stop at a Ctrl-C (`stopped_once_serving`), and each refusal case asserts that it never served. - **B9, a copy left by a crash.** A SIGKILLed serve's copy stayed until a later serve took its port. A publication now sweeps, under the console directory's lock, every copy whose reservation is free, and every temporary or taken name that a dead writer or remover left. Nothing else is touched: not a running console's copy, not a loosened copy (which 12.4a refuses and never replaces), not a link, and no file of another name. Cases: the sweep's rules in the process, and the reviewer's SIGKILL across two real serves. - **B10, the body.** This body was rebuilt from the evidence at the head: the suite, every mutant, the browser and the governed run. Mutant run 16 at `fb8a1cc4` also left one mutant alive: M32, where the configured snapshot is not a served root. Every case named the snapshot at a copy that already existed, so the registered entry's own snapshot (M32b's line) refused it either way. `9fe57dff` adds a snapshot named at `<state>/console/<port>.html` before that copy exists, which only the configured snapshot's own served root refuses. Two follow-ups after round 9. `c4e6c01b` adds B3's hint line, once Brett had ruled it. `0539f8c0` answers mutant run 18 at `45958bf3`, whose one survivor was M36c: the removal never closed the descriptor that reserved the copy, and nothing asked about it. `test_a_removal_releases_the_copys_reservation` now asks that, after a removal and where the directory is gone already, no descriptor of this process is the copy's file. That case's first failure printed the copy's `repr`, and with it the token. So `opened_url` is kept out of `PrivateCopy`'s `repr`, and `test_a_copys_repr_never_carries_its_token` pins it. ## Fix round 10 (`af2a2efb`) Copilot's review at `0539f8c0`, two threads, both on round 9's tokenless guard. Each case failed first at `0539f8c0`: - **`r4179091592`, one walk for the tokenless plane.** The boundary check and the marking each resolved the configured state path for themselves. A link on that path re-pointed between the two left the boundary judging the real state directory and the marking naming a decoy, and an outward static link then served a sibling plane's copy. `guard_private_roots` now walks the state directory once (`_walked`), judging every directory and link on the way as the writer does, and the boundary and the marking both use that walk's path. Cases: - `test_a_tokenless_planes_state_link_retargeted_mid_guard_marks_the_real_directory`, Copilot's layout. The sibling's copy and the listing stay 404; - `test_a_tokenless_planes_unsafe_state_path_refuses_its_start`: a world-writable, non-sticky directory on the way refuses the tokenless start by name. - **`r4179091624`, the platform first.** A tokenless plane skipped the platform check, started, and marked a private root that its handlers then judged with the missing `O_NONBLOCK`. `publish` and the guard now refuse an unsupported platform by name before anything else, token or not. Cases: - `test_a_tokenless_plane_refuses_a_platform_without_the_primitives`, in the process; - `test_a_tokenless_start_without_the_posix_primitives_refuses_by_name`, the no-identity variant of B2's child. ## Fix round 11 (`ed6e4769`) Copilot's review at `af2a2efb`, three threads and one finding in the review's body. Each case failed first at `af2a2efb`: - **`r4179239380`, another state directory.** Two standalone planes of one user can have different `OPENDOX_STATE_DIR` values. The guard knew only its own plane's copies, so if plane A's web root linked to plane B's state directory, A served B's copy. `is_private_file` now first judges the file it was given by what that file holds. A regular file whose head carries a console record is a copy, wherever it lies (`_carries_a_console_record`); the head is read with `pread` from the descriptor already open. Case: `test_another_state_directorys_copy_is_never_served`, through a static link and through a hard link under `/source`. - **`r4179239411`, a removal mid-read.** A copy removed after a read opened it, and before the scan could stat its name, matched nothing in the directory. The open file still holds its record, so it is refused. Case: `test_a_copy_removed_during_the_scan_is_never_served`. - **`r4179239424`, a check that cannot be made.** These used to let the file through, and each now denies it: - a private directory that exists but cannot be listed (`EMFILE`, `EACCES`); - a name in it whose status cannot be read, for any reason but its removal; - a regular file whose head cannot be read. A private directory that does not exist still holds no copy. Cases: - `test_a_private_directory_that_cannot_be_scanned_denies_the_read`, with `EMFILE` simulated, and with `EACCES`; - `test_a_name_whose_status_cannot_be_read_denies_the_read`; - `test_a_file_whose_head_cannot_be_read_is_denied`. - **Previously missed, an entry's payload.** On a standalone plane, an entry with both an in-memory payload and a snapshot file served the file, and a 404 where the file was missing. That broke `SnapshotEntry.read_bytes`' payload-first contract. The payload comes first again, and only the file fallback is guarded. Case: `test_an_entrys_payload_comes_before_its_guarded_file`, with the file missing, present, and a private copy. ## Fix round 12 (`a2e36652`) Copilot's review at `1e114a19`, `r4179793524`. Round 11 recognized a copy in another state directory only by its whole record. So another plane's temporary file, part-written with the token in its meta refresh and no record yet, passed the static handler, `/source` and `/snapshot.json`. A file that grew after it was judged passed too, because the stdlib copies a static file to its end as it is when read. - **A marker first.** The writer now begins every copy with `COPY_MARKER`, an HTML comment, ahead of any byte of the token. A copy is written from its start, so any part of it that holds a byte of the token already holds the whole marker. `is_copy_bytes` recognizes a copy by that marker, or by its whole record. - **What is read is judged.** `read_unless_private` reads first, then judges what it read, as well as the file by its descriptor. - **What is sent is bounded and judged.** The static handler's `copyfile` sends at most the length the file had when it was judged. It reads the body's first bytes before sending anything and judges them, so a copy's bytes are never sent. Cases, each failing first at `1e114a19`: - `test_another_state_directorys_partial_copy_is_never_served`, Copilot's layout, through all three readers, GET and HEAD; - `test_a_file_that_grows_after_its_static_check_never_sends_a_token`; - `test_a_file_that_grows_after_its_read_check_never_returns_a_token`; - `test_a_copy_starts_with_its_marker_before_any_token_byte`; - `test_a_copy_replaced_after_its_read_is_never_returned`, which pins the read-first design. The identity match against this plane's own `console/` remains as defence in depth. `1e114a19` had pinned it with a part-written copy (mutant run 23 left M38 alive); with the marker such a copy is known by its bytes, so `test_a_file_in_the_copies_directory_is_refused_by_its_place` now holds a token-bearing file there that the marker cannot recognize. ## Fix round 13 (`c5fcdfa4`) Copilot's review at `a2e36652`, `r4180089809`. `urlsplit` reads `http://evil.example\@127.0.0.1:8080/index.html` as user information at `127.0.0.1`. A browser takes the backslash for a slash and navigates to `evil.example`, whose page could then read the token's fragment. The entry points build their own URLs, but `write_private_copy` and `opened_url` are public. `_refuse_page_url` now requires the authority to be exactly a loopback host, spelled as `serve.server_url` spells it, with an optional port of at most 65535. A backslash or a control character anywhere in the URL is refused. Cases: - `test_a_page_url_a_browser_reads_as_another_host_is_refused`, eight URLs, Copilot's first. Seven failed first at `a2e36652`; - `test_every_loopback_page_url_a_plane_announces_is_accepted`. ## No test server outlives its run (`d466c1d2`) The holder found orphaned `python -m opendox.serve` children of these cases on the machine, hours old. Each was plane B of the B1 case, left by a mutant run: where a mutant made B serve instead of refusing, the case failed, and its `finally` stopped plane A only. Every server child the cases start now runs in its own session, and is reaped with its process group at teardown, pass or fail: SIGTERM, a bounded wait, then SIGKILL. On Linux it also gets SIGTERM from the kernel if the test process dies first (`PR_SET_PDEATHSIG`), since a killed run runs no teardown. Cases: `test_a_server_left_running_is_reaped_with_its_group` and `test_a_server_outlives_no_killed_run`. Re-run under mutant M44, the B1 case fails as it must, and no server from that run survives. ## Known limits, accepted for release 1 After a serve restart, a tab opened against the old serve holds a stale token in its `sessionStorage`. The fixed "reload the page" messages (`doxbench-chat.js`, `staging-workbench-model.js`, T102's area) then name the wrong remedy on a standalone plane: a reload keeps the stale token. The remedy is the new tab the restart opened, or the new console file. The holder ACCEPTED this for release 1 and passes the copy change to T102's writer. **Some browsers cannot open the copy** (adversarial review B3). Ubuntu's default snap browser, and Flatpak browsers, are kept out of hidden directories such as `~/.local/state`, and a Windows browser under WSL may not open a Linux path. Brett ruled this an accepted limit for release 1, with a hint (*"Hint line, accepted limit (Recommended)"*, 2026-10-04): the start prints one line, with no token, saying to set `OPENDOX_STATE_DIR` to a folder that is not hidden and start again. The README's side is openDox#17's (T076). **The browser's persistent history keeps the fragment** (adversarial review B7). `history.replaceState` strips the token from the address bar and from the tab's session history, but the browser's own history store (Chromium's `Default/History`) has already recorded the opened URL, fragment included. The holder ruled this a limit within the ruled design: the history file is this same user's data, as the 0600 copy is, and it is not served or sent anywhere. ## Tests `tests/test_console_token_delivery.py` (new): - a standalone plane carries no token on `/capabilities`, under no key and in no byte; - a host's plane keeps it there and writes no copy; - **a second OS user cannot obtain it.** First, by asking: 102 GET and HEAD requests (51 paths: the whole bundle of 42 files, `/`, `/capabilities`, the snapshots, the project register, `/source`, the guarded reads without the token), and 8 POST routes. No body and no header carries it. Second, by the copy: the file is 0600 and the directories 0700, all this user's. A copy owned by another uid is refused by the reader (simulated through `getuid`); - the opened URL, the meta refresh and the link carry the token in the fragment only, with an empty query; a page URL that already has a query or a fragment, is not http, or is not loopback is refused; - the record cannot break out of its `<script>`: a hostile `</script><img …>` value stays inside, and the record round-trips; - `generate-and-open` hands the opener a `file://` path, prints the copy's path and never the token, and removes the copy; `--no-open` opens nothing and still prints the path. These cases serve once and stop at a Ctrl-C, since `--no-serve` writes no copy; - an unsafe state directory refuses the run, naming the directory; - the copy is born 0600 in a 0700 tree even under umask 0; - these are refused: a planted link, a dangling link (nothing is created at its target), a hard link, a loosened copy (0644), a planted directory, a linked `console/`, a group- or world-writable state directory, and a non-sticky shared parent (a sticky one is accepted); - a later copy replaces this user's earlier one, and removal is exact; - every guarded route refuses without the token, or with a wrong one, and passes the console check with the copy's token; - **the served-root boundary:** the state directory equal to the served root, under it, under a declared source root, through a link into it, and through `generate-and-open`. Each is refused by name, and nothing is written; - **the reverse boundary:** a served root that is `<state>/console`, `<state>/postgres/run`, a deeper path under the state directory, or the state directory itself. Each is refused by name, and nothing is written. Separately, a link in a source root that points at the state directory gets 404 from `/source`; - the plane reports its served roots: the checkout and each declared source root; - **fix round 3:** a directory's index page linked to a copy is never served, for either index name; a FIFO at the copy is refused at once by the reader and by the writer; - **12.4a:** the writer refuses a loosened own copy and another user's file, and leaves each as it was. Both entry points refuse their start by name for each of five planted kinds. A served root named through a link into the state directory is refused. A `console/` that is not 0700 is refused, and a setgid one is accepted; - **the lifecycle:** an unwritable state directory and a full disk are refusals by name, and no partial file is left. A failed read-back leaves no copy. Another serve's copy survives a removal where hard links fail. SIGHUP removes the copy at all three entry points, and a `nohup` SIGHUP stays ignored. A SIGTERM while the browser opens removes the copy, hosted and local; - **one walk:** a directory passed through by an intermediate link is judged, and a link swapped after the checks never redirects the write; - **fix round 5:** a snapshot inside the state directory refuses the start, through a link too. The snapshot files are reported as served roots. A copy published during a rename-back is never overwritten. A stop during publication, at either entry point, or during removal leaves no copy; - **fix round 6:** an overlong or unsearchable state path is a refusal by name, for the writer, the reader and both entry points; - **fix round 7:** Ctrl-C right after the copy's rename, or right after a removal's take, leaves nothing behind. Ctrl-C is taken only from Python's own handler; - **fix round 8:** a running console's copy is never replaced, by a second publication or by a real IPv6 plane on the same port number, and a dead console's copy is. A source root or a snapshot retargeted after publication, a hard link to the copy in the bundle or the checkout, and a link swapped after the static check never serve the copy; - **fix round 9 (the adversarial review):** a tokenless standalone plane refuses a state directory inside its checkout, as a real second plane and in the process, and never serves a sibling's copy through a link or a hard link. A platform without the POSIX primitives is refused by name, through `serve` and by the writer and the reader. A second spelling of the state directory, of a root inside it, of a root holding it, or of `console/` is judged by its identity. Only the first stop is raised, so a second one never leaves a copy. The walk's link-owner rule, the reader's re-judging of the tree and the `fchmod` under umask 0o277 are pinned. `--no-serve` writes, opens and prints no copy. A dead console's copy, temporary file or taken name is swept, and nothing else is. A snapshot named at a copy not yet written refuses the start. Both entry points print the hint line once, right after the copy's path, and no line they print carries the token. A removal releases the copy's reservation, and a copy's `repr` never carries its token; - **fix round 10:** a tokenless plane whose state link is re-pointed mid-guard marks the real directory, and the sibling's copy stays 404. A tokenless plane refuses an unsafe state path, and a platform without the POSIX primitives, by name, in the process and as a user starts it; - **fix round 11:** another state directory's copy is never served, through a static link or a hard link. A copy removed mid-read is still refused. A private directory that cannot be listed, a name whose status cannot be read, and a head that cannot be read each deny. An entry's payload comes before its guarded file; - **fix round 12:** a copy begins with its marker, before any token byte. Another state directory's part-written copy is refused by the static handler, `/source` and `/snapshot.json`. A file that grows after its checks, or is emptied after its read, never hands out a token.; - **fix round 13:** a page URL whose authority a browser reads as another host (a backslash, user information, a bad port) is refused, and nothing is written; every loopback spelling a plane announces is accepted; - **no test server outlives its run:** a server left running is reaped with its process group, and a child outlives no killed run; - end to end, as a user runs it: `python -m opendox.cli generate-and-open --local --no-open` and `python -m opendox.serve`, each in a child process with neither sibling importable. `tests/test_console_token_view.py` (new, node): fragment taken, kept and stripped; a host's token wins; the degraded probes; a reload keeps the token from storage; the query string is never read; a malformed token is stripped and not kept; blocked storage keeps the token in memory. These standalone children read the token from the private copy (`standalone_child.Child.console_token`), because they used to read it from `/capabilities`: - `test_capability_honesty.py`, its 4 standalone cases and the unknown-tile-kind thread read; - `test_neutral_turn_scope.py`; - `test_doxbench_defaults.py`; - `test_chat_model_configuration.py`'s standalone fixture; - T103's `test_loopback_host_gate.py` real local serve, which now asserts that no token is on `/capabilities` and that the copy exists exactly when a token is minted. **Mutants, 90 of 90 killed** (each applied alone, both new test files run, in a throwaway worktree of `c5fcdfa4`): | mutant | failed | |---|---| | M1 token back in /capabilities | 4 | | M2 query string instead of fragment (server) | 6 | | M2b page reads the query string instead of the fragment | 4 | | M3 the file at 0644 (one constant) | 8 | | M3b the file written 0644, reader unchanged | 89 | | M4 no link check at all | 18 | | M4b no planted-target check on write | 15 | | M4c the read follows a link | 1 | | M5 the page never strips the fragment | 3 | | M6 no served-root boundary | 19 | | M6b the boundary checks equality only | 8 | | M6c the plane reports no served root | 9 | | M7 removal puts no replacement back | 3 | | M8 a plain kill is not read as Ctrl-C | its own SIGTERM ended the run (rc -15), after 5 failures | | M9 the static bundle is not a served root | 3 | | M9b the sessions container is not a served root | 2 | | M11 the static handler serves a private copy | 4 | | M12 no reverse check (a served root inside the state dir) | 9 | | M13 /source follows a link out of its root | 1 | | M14 a directory request serves its index page unjudged | 2 | | M15 the read blocks on a FIFO | 1 | | M16 the writer replaces a loosened own copy (12.4a) | 3 | | M17 the console directory's mode is not judged (12.4a) | 3 | | M17b the setgid bit counts as a loosened mode | 1 | | M18 an operating-system refusal escapes raw | 8 | | M19 a failed read-back leaves the copy | 1 | | M20 no rename-back where a hard link fails | 2 | | M21 a hangup is not read as Ctrl-C | 4 | | M21b nohup's ignored hangup is overridden | 1 | | M22 cli: the handler covers only the serve loop | 3 | | M23 a partial temporary file is left | 1 | | M24 a served root is not judged where its link leads | 2 | | M25 the walk judges no directory it passes through | 2 | | M26 the write is not anchored to the walk | 1 | | M30 serve: the handler is installed only after publication | 5 | | M31 a stop is never held | 1 | | M32 the snapshot file is not a served root | 1 | | M32b a registered entry's snapshot is not a served root | 1 | | M32c the session snapshots' container is not a served root | 1 | | M33 a removal takes no lock | 1 | | M33b a publication takes no lock | 1 | | M34 the walk runs outside the writer's conversion of OS errors | 6 | | M34b the reader converts no OS error | 2 | | M35 Ctrl-C is not held | 5 | | M35b a host's own Ctrl-C handler is overridden | 1 | | M36 a running console's copy is replaced | 2 | | M36b the copy is never reserved | 3 | | M36c removal keeps the reservation | 1 | | M36d removal keeps the reservation where nothing is left to remove | 1 | | M37 /source reads unguarded | 5 | | M37b a registered snapshot reads unguarded | 3 | | M37c the static handler judges no identity before it opens | 4 | | M37d the static backstop sends what the stdlib opened | 1 | | M38 is_private_file matches no identity | 1 | | M39 the boundary ignores identity (B4) | 3 | | M40 the static guard ignores identity (B4) | 1 | | M41 the first stop is not latched (B5) | 2 | | M41b a held stop is raised after the first (B5) | 1 | | M42 no sweep (B9) | 2 | | M42b the sweep ignores reservations (B9) | 1 | | M42c the sweep removes what is not this user's own copy (B9) | 1 | | M43 --no-serve publishes (B8) | 1 | | M44 a tokenless plane guards nothing (B1) | 5 | | M44b the delivery depends on the token (B1) | 7 | | M44c a tokenless plane marks no private root (B1) | 2 | | M44d a tokenless plane asks no boundary (B1) | 3 | | M45 the writer refuses no platform (B2) | 1 | | M45b the reader refuses no platform (B2) | 2 | | M46 (reviewer m2) the walk never judges a link's owner (B6a) | 1 | | M47 (reviewer MA) the reader never re-judges the tree (B6b) | 1 | | M48 (reviewer m1) no fchmod (B6c) | 1 | | M49 (reviewer MC) the page keeps the token in localStorage | 2 | | M50 generate-and-open prints no hint line (B3) | 1 | | M50b serve prints no hint line (B3) | 1 | | M51 a copy's repr carries its token | 1 | | M52 the tokenless guard resolves the state path again to mark it | 1 | | M52b the tokenless guard does not walk | 1 | | M53 a tokenless plane skips the platform check | 2 | | M54 a copy is not known by what it holds | 3 | | M54b a head that cannot be read is allowed | 1 | | M55 a directory that cannot be listed allows | 2 | | M55b a name whose status cannot be read allows | 1 | | M56 an entry's file comes before its payload | 3 | | M57 a copy is written without its marker first | 4 | | M57b a copy's marker is not recognized | 4 | | M58 the reader judges none of the bytes it read | 1 | | M58b the reader judges the file before it reads it | 2 | | M59 the static body is copied as the stdlib copies it | 1 | | M61 the page URL's authority is not judged exactly | 4 | | M61b a backslash or a control character passes | 2 | M10 (the copy's own path not judged) is retired with the check it mutated: the reverse rule replaced that check, and M12 is the mutant that removes the reverse rule. **The browser, end to end.** Chromium (Playwright 1.61.0, the T096 prep harness's driver) ran `opendox generate-and-open --local --no-open` from a fresh install. The first run was on a LOCAL, never-pushed integration of this branch with #77 and `main`. It was re-run at `60bace00`, at `182cac76` and at `c5fcdfa4`, which carry both. 18 of 18 checks passed every time: - the private copy (0600) forwards to `/index.html`; - the address bar and the tab's session-history entry carry no fragment. The browser's persistent history still records the opened URL; see "Known limits" above; - `sessionStorage` holds the token, and `probeCapabilities` fills it; - the page's own `/capabilities` has none; - the catalog answers 200 with the token and `console_required` without it; - a reload keeps the token, and a new tab at the bare URL has none; - no request URL or `Referer` carries the token, and there was zero `pageerror`; - nothing the server printed carries the token; - the copy is gone after SIGTERM, and no bundled PostgreSQL is left. **The whole suite, locally** (`tests` and `tests_runtime`, with `--basetemp` and `TMPDIR` outside the tree): | commit | passed | skipped | |---|---|---| | `6db50b03` | 3415 | 177 | | `cb899546` | 3422 | 177 | | `c979747a` | 3424 | 177 | | `60bace00` | 3774 | 177 | | `d4b99436` | 3821 | 177 | | `a13857ad` | 3851 | 177 | | `182cac76` | 3858 | 177 | | `14285fbb` | 3860 | 177 | | `ddb26c34` | 3894 | 177 | | `9f328892` | 3901 | 177 | | `9dcc9bb5` | 3907 | 177 | | `fb8a1cc4` | 3911 | 177 | | `0539f8c0` | 4078 | 177 | | `af2a2efb` | 4082 | 177 | | `ed6e4769` | 4091 | 177 | | `d466c1d2` | 4093 | 177 | | `1e114a19` | 4094 | 177 | | `a2e36652` | 4099 | 177 | | `c5fcdfa4`, the head | 4111 | 177 | At the head, 0 failed. The count grew with the merges: #80's final head carried main's landings since `d0f1efcb` (#63, #69, #73, #64, #72 and #77), and then #81, #85, #76 and #82 landed. CI's own reading at `c5fcdfa4` is `selected=4288 passed=4277 skipped=11`, against `validate.yml`'s floors 3977 / 3966 and its exact 11. ## The governed path openxFactory's existing token-reading suites were run twice, locally only. First against the pinned openDox-code `047bb4fa`, then against `047bb4fa` with T104's commits applied. The last such run applied every server-side change through `c5fcdfa4`, at local `f18e3342`. That is the head's whole server side, rounds 8 to 13 included. `cli.py`'s hunks are left out there: the governed host serves through `opendox.serve`, and the pin's `cli.py` predates T084's refactor. The suites are `tests/ideation-dashboard/test_doxbench_routes.py`, `test_gate_routes.py`, `test_shared_identity.py` and `test_staging_seed.py`, and they read `caps["console_token"]` through `_console_token`. The result is identical: **239 passed, 1 failed at both**. The one failure is the same pre-existing case (`test_lens_add_as_cluster_lands_manifest_pending_and_record`, 409). Nothing in openxFactory changes, so there is nothing for T094. **For T086 (openXdox-code), recorded with the holder.** openXdox-code `6a3b93b9`'s route harnesses (`tests/doxbench_routes_harness.py:290`, `tests/gate_routes_harness.py:139-149`) build the server with no host profile registered, so by this PR's rule their plane is standalone, and they read `caps["console_token"]`. Composed with openxFactory's `scripts/` for `doc_health`, 90 cases there fail with `KeyError: 'console_token'`. All 90 are in 6 files that its `tests/declared_exclusion.yaml` already excludes (`doc_health`), so its CI does not run them. When its openDox pin passes this PR, those harnesses read `httpd.console_token`, or register openXdox's profile as a host. ## Batch N, and T095 T007's batch N landed in openxFactory#1222 (`bdd0f586`): #1144 12.4a's amendment is the normative text this PR realizes (see "12.4a, clause by clause" above). F10.1, F13.1, F16.1 and F12.x are unaffected. Plan 034's quickstart § 3 and § 4 step 1, and AT-R1 step 4, read the private copy. T095's harness (openDox-code#75, not edited here) reads the private copy at `<state_dir>/console/<port>.html` instead of `/capabilities`. The holder has its proposed helper and the three line changes. The helper never opens a copy that failed its checks, so a FIFO cannot block it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ine) (plan 034) (#78) Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Plan 034's **T101, "T099's release step"** (with openDox-code#79): a release workflow that publishes openDox to PyPI by trusted publishing (OIDC). It makes release 1's ruled install line, `pip install "opendox[local]"`, work as written (#1144 10.3, as T007's batch H addendum reads, R1Q15 (b) and R1Q16 (iii), RULED `5850003126`). Today `https://pypi.org/pypi/opendox/json` and `https://test.pypi.org/pypi/opendox/json` both answer 404. - **Ruled**: `5962754358`, item 1 (Brett Heap, 2026-10-02): *"Publish to PyPI at the cut (Recommended)"*. The release commit and version are RULED in `5963162921`, *"0.1.0 (Recommended)"*. The bump to 0.1.0 is the last phase-3 openDox-code landing, T087 pins that commit, and the workflow publishes only the commit the openDox root's `contracts/code-pin.yaml` names. - **The plan's split** is the holder's ruling, after Copilot's review of openxFactory#1220. **T101** is this PR plus the bump (#79), and lands before T087. **T099** is the publish alone, after T101, T087 and T089. The plan entry is openxFactory#1220 (bookkeeping, no `Arc:` trailer). - **Ready for the holder, after the final `main` merge.** The title's DRAFT prefix is dropped. The holder posts READY, and the landers merge: this PR first, then openDox-code#79, which merges `main` again first if it needs to. **Nothing has been published** to PyPI or TestPyPI. Nothing in this PR can publish until Brett configures the two indexes (below) and dispatches it on the tag `v0.1.0`. The two environments are configured already, by the holder on 2026-10-05. - **openDox-code#69 (T072) and #73 (T075) have landed, and this branch merges them** (`main` `8e377823` was merged at `7cc65f93`; the head is now `07cc8f36`: `main` `651c35fe` was merged at `e5f7f554`, then rounds 10, 11 and 12 followed, as the sections below read). The build job passes on this head's own tree (proof 1). Before they landed, the verify step refused, naming exactly what those two PRs add (proof 2). ## What it adds **`.github/workflows/release.yml`.** Its only trigger is `workflow_dispatch`, with one input, `version`. It has no push, tag or pull-request trigger. Four jobs run in a chain: 1. **`build`** (`contents: read`, `actions: read`; it mints no identity token, and its `GITHUB_TOKEN` reads only). In order: - **The release is the pinned commit, on `main`**, in two steps: - (a) the ref and `main`: a dispatch on a tag must name `v<version>`, and the run's commit must be on this repository's `main`. The compare API must read `identical` or `ahead`. - (b) the pin: the run's commit must equal `commit:` in opensoft/openDox `main`'s `contracts/code-pin.yaml`, whose `source_repository` must be this repository. **Both publish jobs run this same step again, right before their upload** (below). - `main`'s head is never required, because T095 may land after T087 has pinned the bump. - A dispatch runs at the head of the ref it names. So the release is dispatched on the tag `v<version>`, which the holder creates at the commit T087 pinned, at the cut, on Brett's word. A dispatch on any other tag is refused. A dispatch on `main` or any branch builds and verifies, but cannot deploy: both environments deploy only from the tags `v*` (below). - **`main` is always `refs/heads/main`**, in the root's fetch and in the compare. git resolves a bare `main` on the remote as a tag before the branch, and a tag needs no review (round 11). - The root's pin is read **anonymously over git**: a depth-1 fetch of opensoft/openDox's `refs/heads/main`, with every credential helper cleared and no prompt, in a repository of its own under `$RUNNER_TEMP`. So the read of another repository depends on no token. The compare reads this repository, through the API. - **Both environments ask a reviewer and limit the refs that deploy.** GitHub creates an environment that a job names if it does not exist yet, and creates it with no protection rule. So a dispatch made before Brett set up `pypi` would publish with nobody approving it. And a dispatch runs the dispatched ref's own copy of `release.yml`, while the trusted publisher never matches the ref, so an environment any ref may deploy to would publish whatever a branch carries. The step refuses unless `testpypi` and `pypi` each have a `required_reviewers` rule with at least one reviewer, AND a `deployment_branch_policy` with `custom_branch_policies`, a custom deployment limit (round 11). The limit's pattern, the tag `v*`, is configured and was verified through the API, but the workflow does not re-read it: changing it is an admin act (an accepted limit, RULED `5993462741`). - **The release tools come from a hash lock**, `.github/release-tools-cpython312-linux.txt`, installed with `--only-binary :all: --require-hashes` into a venv of their own. - **The version gate.** The input must be a PEP 440 version in normalized form, with no epoch (a wheel's file name escapes its `!`) and no local label. It must be a final release. A pre-release or a development release is refused, because the dry run installs the unversioned `opendox[local]`, which pip resolves to a final release once one exists. It must not be `0.0.0`, the scaffold's placeholder, and it must equal `pyproject.toml`'s `[project] version`. - **The build.** `python -m build --no-isolation` builds the sdist, then the wheel from that sdist, with `SOURCE_DATE_EPOCH` set to the commit's time. - `twine check --strict`. - **The artifact checks, before any upload:** 1. `dist/` holds exactly the sdist and the wheel named for the version; 2. the wheel's metadata names `opendox` at that version; 3. the wheel declares exactly the requirements `pyproject.toml` does, for the base install and every extra, and its `local` extra (T072) carries `opendox[runtime]` and `pixeltable-pgserver`. Requirements are compared as normalized requirements: the name, the extras, the version specifier or direct URL, and the marker. Only the `extra == "X"` clause that setuptools adds to record ownership is removed, read from the parsed marker. An `extra` anywhere else in a marker is refused; 4. the console script `opendox = opendox.cli:main` is declared; 5. every file git tracks under `src/opendox/web/` is in the wheel, dotfiles included (T075's `web/**/.*`); 6. every file git tracks under `src/` is in the wheel at its import path, less `src/.gitkeep`, and the wheel carries nothing else but its `.dist-info` and `.data`; 7. every tracked `migrations/*.sql` is in the wheel's `share/opendox/migrations/` (T072). The tracked tree is the reference, so a bundle file added later is checked with no edit here. - **The digests are recorded before any third-party code runs.** Right after the artifact checks, the two file names and their sha256 become the job's outputs. Until then only the hash-locked release tools and this package's own build have run (round 10). - **The built wheel runs from a fresh venv.** `opendox[local]` is installed from the built file, with dependencies through `constraints-cpython312-linux.txt` and wheels only. Then, from outside the checkout, the step: - asserts the import path and the version; - walks the whole requirement closure of `opendox[local]` from the installed metadata, extras and markers included, and requires every requirement to be installed and satisfied; - runs the bundled server's `initdb --version` and `postgres --version`, through `opendox.runtime.bundle.server_binaries()`; - runs `opendox --help`, and requires `opendox generate-and-open --help` to name `--local`. - **After the smoke test, `dist/` must still hold exactly those two files at those digests**, so a dependency's code that rewrote them refuses there, by name, before the upload (round 10). Then it uploads `dist/`. 2. **`testpypi`**, the dry run, in the `testpypi` environment, with `id-token: write` and `actions: read`. It downloads `dist/` and checks the two files against the build job's digests, and that there is no third file. **It re-checks its approval at use time**: the environment must still name a required reviewer and still limit its refs (`custom_branch_policies`), and this run's review history must hold an approved review of a deployment to it. **A preflight follows**: TestPyPI must hold no file of this version except a verified one at its verified digest, and none of them yanked (a 404 means no file yet). **It checks the root's pin again**, the build job's step, since the run waited on an approval. Then it runs `pypa/gh-action-pypi-publish` with `repository-url: https://test.pypi.org/legacy/`, bounded at 10 minutes. Every step declares its own timeout, and the job's (25 minutes) covers their sum (24). 3. **`testpypi-install`** (`contents: read`): - TestPyPI's JSON API must serve exactly the two verified files and digests, neither yanked (a yanked file refuses at once). It is read until it does, up to 20 reads 15 s apart, so a partial or stale answer during propagation is retried, not taken as final. A read that times out is retried too. - Then T099's falsifier runs in a fresh venv: `pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ "opendox[local]"`. The requirement is **unversioned, as written**. The lock, wheels-only (so no package's code runs at install) and `--report` are added. - **The installed `opendox` must be the verified TestPyPI wheel, proven before anything from it runs.** pip merges candidates from both indexes, and `opendox` is not reserved on PyPI before its first release. So this job's own `python3`, never the venv's interpreter, reads pip's report. The one `opendox` installed must come from `test-files.pythonhosted.org`, at the version just uploaded, with the verified wheel's sha256. - An older `opendox` means a lagging index, and that try is repeated. - Any other `opendox` refuses at once, and nothing from it is run: another host, another digest, or a newer version. - Each try is a fresh venv, up to 10 tries, 30 s apart, and each is cut off after 300 s. Then `opendox --help`. - **Every step declares its own timeout, and the job's (90 minutes) covers their sum**: checkout 5, setup-python 5, the JSON step 16 (20 reads, each up to 30 s, 15 s apart), the install 60 (10 tries, each up to 300 s, 30 s apart). A test reads each bound from the workflow. - The trade of using two indexes is stated in the step's comment. The install step receives no token, no OIDC grant and no secret. The job's `GITHUB_TOKEN` (`contents: read`) is used only by its checkout, which does not persist it. The files PyPI receives are still checked by digest. 4. **`pypi`**, in the `pypi` environment, with `id-token: write` and `actions: read`, and a 45-minute timeout that covers its steps' declared bounds (5+2+2+2+3+10+16). The same digest check, **the same approval re-check**, the same preflight against PyPI, **the root's pin checked again** (the run waited on two approvals and on TestPyPI), then `pypa/gh-action-pypi-publish`, bounded at 10 minutes, so the check after it always has its 16. Then PyPI's JSON API must serve exactly the two verified files, by digest, neither yanked. That is the same script as TestPyPI's check, reading `INDEX`, `JSON_BASE`, `READS` and `PAUSE` from its step's `env`. **Both uploads set `skip-existing: true`.** A re-run after a partial upload then uploads what is missing, instead of stopping on the file the index already holds. A skipped file could differ from the verified one, so the preflight refuses before any upload if the index already holds a file at any other digest, and an index never replaces a file. The JSON check after each upload then requires exactly the build job's two digests. A yanked file stays yanked, and an unpinned install skips it, so the preflight refuses a yanked file and the check after the upload does too, naming the remedy: un-yank it on the index, or release a new version (round 10). **No PyPI token and no stored secret appear anywhere.** `id-token: write` is granted to `testpypi` and `pypi` only, and the workflow's default is `permissions: {}`. The one other credential is GitHub's own automatic `GITHUB_TOKEN`, passed to `gh` for reads of this repository's own API only. In the build job, it reads the compare with `main` and the environments (`contents: read`, `actions: read`). In each publish job, it reads that job's environment and this run's review history (`actions: read`). The openDox root's pin is read over git with no credential at all. Inputs reach every script through `env:`, never through an expression pasted into a script. **Every action is pinned by full commit SHA**, each SHA resolved from its release tag through the git refs API: | action | tag | commit | |---|---|---| | `actions/checkout` | v7.0.1 | `3d3c42e5aac5ba805825da76410c181273ba90b1` | | `actions/setup-python` | v7.0.0 | `5fda3b95a4ea91299a34e894583c3862153e4b97` | | `actions/upload-artifact` | v7.0.1 | `043fb46d1a93c77aae656e7c1c64a875d1fc6a0a` | | `actions/download-artifact` | v8.0.1 | `3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c` | | `pypa/gh-action-pypi-publish` | v1.14.2 | `dc37677b2e1c63e2034f94d8a5b11f265b73ba33` | `validate.yml` pins by tag. This file departs from that on the brief's word: a tag can be moved after review, and a commit cannot. **One limit, accepted** (D8, F3): `pypa/gh-action-pypi-publish` is a container action, and called from any repository but its own it runs `ghcr.io/pypa/gh-action-pypi-publish:<sha>`, pulled by registry tag (its `create-docker-action.py`, read at `dc37677b`). So the step that holds the OIDC token runs an image that a tag names. The follow-on is a hash-locked `twine upload` with the documented manual OIDC token exchange. It edits no other workflow. openDox-code#75 (T095) edits `validate.yml` only, so the two do not overlap. **`.github/release-tools-cpython312-linux.txt`.** It hash-locks `build==1.6.1`, `twine==7.0.0` and `setuptools==84.0.0`, with their dependencies (30 packages), compiled by `uv pip compile --generate-hashes` for cpython 3.12 on x86_64 linux. Its header gives the command. - It is separate from `constraints-cpython312-linux.txt`. That file is this package's own resolved dependency tree, which `tests_runtime/test_deploy_shape.py` holds every install of the package to. These are the tools that build the package. - `setuptools==84.0.0` is the version #69 pins there for the `test` extra. **`tests/test_release_workflow.py`** was added at Copilot's request. It holds 129 hermetic cases that run the steps' own scripts, extracted from `release.yml`, as `tests/test_triple_pin.py` runs `validate.yml`'s pin: - the shape: dispatch only, one input, no secret, `id-token` on the two publish jobs alone, each in its own environment, every action pinned by full SHA, and the job chain; - the release-commit steps (10 cases, run as the build job runs them, the ref step then the pin step), with a stand-in `gh` first on `PATH` for the compare. Each case builds a stand-in openDox root and redirects the root's URL to it through git's `url.<base>.insteadOf`, set in the environment. It also sets `GIT_ALLOW_PROTOCOL=file`, so a redirect that failed would refuse rather than reach GitHub. A structural case holds the anonymous read: no `contents/` API call, the fetch line with `-c credential.helper=`, no `gh` call and no `env` in the pin step, and exactly one `gh` call in the ref step, which is the compare, against `refs/heads/main`. The fetch names `refs/heads/main` too; - a tag named `main` in the root (6 cases): a stand-in root whose branch `main` pins the release commit and whose tag `main`, at a commit off the branch, pins another. In each of the three jobs, the branch's pin passes and the tag's pin refuses (round 11); - the pin again before each upload: in each publish job, the step right before the upload is the build job's pin step. A moved pin refuses there, and an unchanged one passes, in each job (4 cases); - the environment step (5 cases), with the stand-in `gh` answering each environment as the API does: both with a reviewer and a limit pass; no reviewer, an unreadable environment, no deployment limit, and protected branches in place of a limit each refuse; - the version gate (10 cases); - the artifact checks (17 cases), over a hand-built wheel and sdist in a git tree of their own. Their pyproject carries a platform marker on a base requirement, a parenthesized `or` marker on an extra's requirement, and a direct URL. Refused: a marker changed or dropped (an extra's, and a base requirement's), a direct URL in place of a version, a direct URL changed, and an extra under an `or`. Accepted: the extra's clause written first; - the preflight, in both publish jobs: its place before the pin's re-check, the two jobs sharing one script, and 8 index cases each (no file yet, both files verified, one verified file from a partial upload, the sdist at another digest, a file nobody verified, a 503, the verified wheel yanked, and the release yanked); - both publish jobs' digest check (6 cases); - the build job's order (the artifact checks, the digests, the smoke test, the re-check, the upload) and the re-check of `dist/` after the smoke test (4 cases: unchanged passes; a changed byte, a third file and a missing file refuse); - `skip-existing` on both uploads, the two index checks as one script, and PyPI's check as the step after its upload; - the index check against a local JSON server, in both jobs: exact, lagging then exact, another sdist, a third file, a refused read, and the verified files with the wheel yanked (12 cases); - each job's timeout and each step's, all read from the workflow: every step declares one, each job covers the sum of its steps', each upload is at most 10 minutes, and each retrying step covers its script's retries; - the approval at use time, in both publish jobs: the step's place, `env` and the jobs' permissions, one script, and eight cases each (approved; the rule removed; the deployment limit removed; the environment unreadable; no approval; only the other environment approved; rejected; the history unreadable); - the dry run's install: the line's parts (the falsifier's, plus `--report`), and that nothing from the venv runs but pip before the verdict. Nine verdict cases run over pip reports: the verified wheel passes; PyPI at, above and at the verified digest refuses; PyPI below is retried; TestPyPI older is retried; TestPyPI newer refuses; TestPyPI at another digest refuses; no `opendox` refuses. **`validate.yml`'s floors are not moved.** They permit a rise, and `validate.yml` is T095's single-writer file (openDox-code#75), as every other phase-3 test addition has left it. The whole suite at this PR's head is in proof 9. **`pyproject.toml`: `readme = "README.md"`** (the holder's decision). Without it, the metadata carries no long description and `twine check --strict` refuses both files. **README.md's four relative links are made absolute** (`SECURITY.md` and three `docs/` links), so they resolve from the PyPI page too. - **pyproject.toml's single-writer order puts this edit after T072 (openDox-code#69) and T075 (openDox-code#73).** The line sits in `[project]`, away from both PRs' hunks. - **`version` stays `0.0.0` here.** The bump to `0.1.0` (RULED `5963162921`) is openDox-code#79, the last phase-3 openDox-code landing before T087. ## Proofs Every run below is local and never pushed. The harness parses `release.yml` and runs each job's `run` steps exactly as written, under `bash --noprofile --norc -eo pipefail`, with the `uses` steps emulated. **It refuses to emulate `pypa/gh-action-pypi-publish` and stops there**, so nothing is uploaded. **1. The build job on this head's own tree.** The tree is `7cc65f93`, this branch merged with `main` `8e377823`, which carries #69 and #73. One local-only commit sets `version = "0.1.0"`. Round 6's verify step reads the same: ``` 3. requirements: the local extra carries ['opendox[runtime]', 'pixeltable-pgserver<0.7,>=0.6.0'] 5. web bundle: 42 of 42 tracked files are in the wheel 6. source tree: 118 of 118 tracked files under src/ are in the wheel; 0 other entries 7. migrations: 2 of 2 are in the wheel's data directory opendox[local]: 34 distributions, every requirement installed and satisfied initdb (PostgreSQL) 16.14 postgres (PostgreSQL) 16.14 opendox generate-and-open --help names --local wheel-sha256=8e179862e1f6678eee9cd292f177d48718c40bc796eb1620a48ef7c0a3e4e00f JOB build: every run step passed ``` The full run, before #69 and #73 landed: #73's head `0457b383` (which contains #69's head `28e195b9`), merged with this PR's fix-round commit `647d246e`, with the same local-only version commit. The harness runs the steps of this head's `release.yml` (`7cc65f93`) over that tree. The release-commit and environment steps run separately (4 and 5 below). Every other step passed: ``` version 0.1.0: pyproject.toml declares it Checking dist/opendox-0.1.0-py3-none-any.whl: PASSED Checking dist/opendox-0.1.0.tar.gz: PASSED 1. dist/: ['opendox-0.1.0-py3-none-any.whl', 'opendox-0.1.0.tar.gz'] 2. metadata: Name opendox, Version 0.1.0 3. requirements: the local extra carries ['opendox[runtime]', 'pixeltable-pgserver<0.7,>=0.6.0'] 4. console scripts: {'opendox': 'opendox.cli:main', 'opendox-runtime': 'opendox.runtime.cli:main'} 5. web bundle: 42 of 42 tracked files are in the wheel 6. source tree: 117 of 117 tracked files under src/ are in the wheel; 0 other entries 7. migrations: 2 of 2 are in the wheel's data directory opendox 0.1.0 imports from .../runner-temp/fresh/lib/python3.12/site-packages/opendox opendox[local]: 34 distributions, every requirement installed and satisfied initdb (PostgreSQL) 16.14 postgres (PostgreSQL) 16.14 opendox generate-and-open --help names --local wheel-sha256=3221e83d6b44457616b0f5db9689a7062475f5a77cd221b4eaf7dc92df1f34b7 sdist-sha256=3fdee8308c85abc85ad12ebeba0a1af5513eafa5fe3aa68d8e3c9d3b42ee9e01 JOB build: every run step passed ``` The wheel's digest is the same as the previous run's (`3221e83d…`), since `SOURCE_DATE_EPOCH` fixes its bytes. The sdist's contents are identical (`diff -r` of the two unpacked trees is empty), but its digest differs, because setuptools stamps the generated `PKG-INFO` and the gzip header with the build time. The workflow carries the digests of the one build it verified, so nothing depends on the sdist being reproducible. The installed `opendox --help` still prints the carve-era `usage: ideation-dashboard` text. That is T084's to fix (openDox-code#77), not this PR's. **2. The same job at this PR's head `2b0083b9`** (main `66ff7257` plus this branch, without #69 or #73), with the same local version commit. The verify step refuses, naming exactly what #69 and #73 add: ``` 3. requirements: the local extra carries [] 5. web bundle: 41 of 42 tracked files are in the wheel 6. source tree: 116 of 117 tracked files under src/ are in the wheel; 0 other entries 7. migrations: 0 of 2 are in the wheel's data directory ::error::the `local` extra carries [], not opendox[runtime] and the bundled server's package, pixeltable-pgserver ::error::the wheel lacks web files: ['opendox/web/vendor/.gitkeep'] ::error::the wheel lacks tracked files: ['opendox/web/vendor/.gitkeep'] ::error::the wheel lacks migrations: [...0001_identity_and_coordination.sql', ...0002_migration_state.sql'] JOB build: FAILED at step 9 (verify the artifacts) ``` **3. The version gate** refuses `0.2.0` against `0.1.0`, `v0.1.0`, `0.1.0+local`, `1!2.0`, `banana`, `0.1.0"; print(1) #`, the `0.0.0` placeholder, the pre-release `0.2.0rc1` and the development release `0.2.0.dev1`. It passes `0.1.0` against `0.1.0`, and the post-release `0.1.0.post1`. `tests/test_release_workflow.py` holds each case. **4. The release-commit step against the live opensoft/openDox `main`**, which pins `047bb4fa394f3e1bf42466062a67ef18e99f8d6a` today. openDox-code `main` has since moved past it (now `1130e996`), which is the case T095 will make at the cut. The run below is fix round 3's single step. Round 5 splits it in two with the same scripts, and the pin step's live run follows the block. The step's script was extracted from this head's `release.yml` and run with: - `env -i`; - an empty `HOME`; - `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1`; - `GIT_TRACE_CURL` on. The only token given was the `GH_TOKEN` the compare uses, and git never reads it: ``` == main, pinned 047bb4fa394f3e1bf42466062a67ef18e99f8d6a is the commit opensoft/openDox main pins for opensoft/openDox-code 047bb4fa394f3e1bf42466062a67ef18e99f8d6a is on main (ahead), dispatched on refs/heads/main rc=0 == tag v0.1.0, pinned ... is on main (ahead), dispatched on refs/tags/v0.1.0 rc=0 == main, an unpinned commit ::error::this run is at 9a49040500000000000000000000000000000000, and the openDox root pins 047bb4fa394f3e1bf42466062a67ef18e99f8d6a; a release publishes only the pinned commit. ... rc=1 == tag nightly ::error::a release dispatched on a tag names v0.1.0, and this run names refs/tags/nightly rc=1 curl-trace (main): git requests=3 authorization headers=0 curl-trace (tag): git requests=3 authorization headers=0 ``` The three requests are `GET /opensoft/openDox.git/info/refs?service=git-upload-pack` and two `POST /opensoft/openDox.git/git-upload-pack`. None of them carries an `Authorization` header. At this head, **the `pypi` job's pin step** (the same script as the build job's) was run the same way, with `env -i`, no token and no `python` but the system's `python3`: ``` == the pypi job's pin step, GITHUB_SHA=047bb4fa 047bb4fa394f3e1bf42466062a67ef18e99f8d6a is the commit opensoft/openDox main pins for opensoft/openDox-code rc=0; git requests=3 authorization=0 == the pypi job's pin step, GITHUB_SHA=22222222 ::error::this run is at 2222222222222222222222222222222222222222, and the openDox root pins 047bb4fa394f3e1bf42466062a67ef18e99f8d6a; a release publishes only the pinned ... rc=1; git requests=3 authorization=0 ``` A commit that is not on `main`, such as #73's head, reads `behind` on the compare call, and the step refuses it. **5. The environment step today** refuses both environments with `gh: Not Found (HTTP 404)`, then `::error::the testpypi environment cannot be read (it does not exist yet, ...)`, and the same for `pypi`. Pointed at this repository's `copilot` environment, which has no protection rule, it prints `::error::the copilot environment names no required reviewer, ...`. **6. The publish jobs' digest check** passes on the build job's artifact. It refuses a wheel with one changed byte and a third file in `dist/`, and the harness stops at each publish action. **7. The index checks, live.** The one JSON script, extracted from the `pypi` job, was run against both indexes: ``` == TestPyPI sampleproject 1.2.0: sampleproject-1.2.0-py2.py3-none-any.whl sampleproject-1.2.0.tar.gz TestPyPI serves exactly the verified files: {...} rc=0 == TestPyPI sampleproject 1.2.0, the wheel's digest wrong ::error::after 2 reads, TestPyPI serves {...} rc=1 == PyPI sampleproject 4.0.0: sampleproject-4.0.0-py3-none-any.whl sampleproject-4.0.0.tar.gz PyPI serves exactly the verified files: {...} rc=0 == PyPI sampleproject 4.0.0, the wheel's digest wrong ::error::after 2 reads, PyPI serves {...} rc=1 ``` **`testpypi-install`.** - The install step's line, pointed at a local HTTP simple index that serves the built wheel, with `--extra-index-url https://pypi.org/simple/`, installs `opendox[local]` and runs `opendox --help`. - Both run against TestPyPI itself only at the dry run. **8. Linters.** - `actionlint` 1.7.12: no findings, over both workflow files. - `zizmor` 1.30.1, online audits, `--persona=pedantic`: `No findings to report.` **9. The whole suite** at `d91fd5b9` (`main` `8e377823` merged in), in a venv with the `test` extra, as `validate.yml` installs it, the bundled PostgreSQL included: - `tests/`: `3013 passed, 11 skipped`; - `tests_runtime/`: `631 passed, 166 skipped`. In the earlier suite venv, which lacked the `local` extra's `pixeltable-pgserver`, 5 tests failed and 2 errored at this head, each with `the local install's PostgreSQL server is not installed`. They fail the same way at `main` `8e377823` in that venv, so the failures are environmental. At `b75205d4` (rounds 7 and 8 change `release.yml` and the test file only), `tests/` reads `3023 passed, 11 skipped`, and `tests/test_release_workflow.py` alone reads `93 passed`. `main` has since gained `90ac7033` (#72, T073), which touches `src/` and tests only: no `pyproject.toml`, no workflow and no lock. Mutants, each run against that file: | mutant | result | |---|---| | the pre-release refusal removed | `2 failed` (round 3) | | the URL redirect removed | `7 failed` (each case refuses instead of reaching GitHub; round 3) | | the credential helper left in place | `1 failed` (round 3) | | the token-backed `contents/` API read restored | `8 failed` (round 3) | | no `skip-existing` on the TestPyPI upload | `1 failed, 56 passed` (round 4) | | `testpypi-install` back at a 20-minute timeout | `1 failed, 56 passed` (round 4) | | no per-try `timeout 300` on pip | `1 failed, 56 passed` (round 4) | | the index check compares names, not digests | `2 failed, 55 passed` (round 4) | | no PyPI check after the upload | `6 failed, 51 passed` (round 4) | | no pin re-check in the `pypi` job | `3 failed, 59 passed` (round 5) | | the `pypi` job's re-check ignores the commit | `2 failed, 60 passed` (round 5) | | markers dropped from the requirement comparison | `4 failed, 78 passed` (round 6) | | a direct URL ignored | `1 failed, 82 passed` (round 6; the changed-URL case was added for it) | | a preflight that accepts any held file | `4 failed, 78 passed` (round 6) | | no preflight in the `pypi` job | `7 failed, 75 passed` (round 6) | | the PyPI upload unbounded; at 30 minutes; the `pypi` job at 30; PyPI's JSON check at 10 | each fails the timeout test (round 7) | | the verdict ignores the host; ignores the digest; the venv's interpreter run before it; no `--report` | each fails (round 8) | | any environment's approval counts; a rejected review counts; the rule not re-read; no approval check in `pypi` | each fails (round 9) | **10. The approval check, live.** The step's script ran with `env -i` and a read token against a real run of OpsxFactory, whose `github-administration` environment is reviewer-protected: - for `github-administration`, the environment that run deployed to, it passes: `a required reviewer is named, and this run's deployment was approved by brettheap`; - for `endpoint-verification`, which that run did not deploy to, it refuses: `holds no approval of a deployment to endpoint-verification`; - against this repository's `pypi`, which does not exist yet, it refuses: `the pypi environment cannot be read`. **11. The verdict, on real pip reports.** In fresh pip 24.0 venvs, a two-index install of `sampleproject` resolved `4.0.0 from files.pythonhosted.org`: PyPI's file, which is the risk itself. A TestPyPI-only install resolved `1.2.0 from test-files.pythonhosted.org`. With each report's name rewritten to `opendox`, the step's verdict script refused the first (`... which is not the verified wheel`, rc 2) and passed the second (`opendox 1.2.0 is the verified wheel, from test-files.pythonhosted.org`, rc 0). ## Copilot's findings at `de3e3249` - **The repository token and the public root** (r4171040914). The pin is now read anonymously over git, as above, so the read depends on no token. - You cite GitHub's documentation that the token is limited to the workflow's repository. The estate also has evidence that the token reads a public repository of the same org over git: openxFactory's `openreposhape-pin-gate.yml` run `37085426729` checks out public `opensoft/openRepoShape` with the default `github.token` (`Contents: read`, `Metadata: read`), and succeeds. - The REST call under that token was not measured, and the change makes the question moot. - **Pre-releases and the unversioned install** (r4171040935). The version gate refuses them, through `Version.is_prerelease`. `VERSION_CASES` holds `0.2.0rc1` and `0.2.0.dev1` (both refused) and `0.1.0.post1` (accepted). ## Copilot's findings at `7cc65f93` and `74b87375` - **A partial upload is not recoverable** ("previously missed" at `7cc65f93`): both uploads set `skip-existing`, and PyPI's served digests are now checked after its upload too (round 4, `74b87375`). - **The retry budget exceeds the job timeout** ("previously missed" at `7cc65f93`): `testpypi-install` is at 75 minutes, its budget in full, with each pip try cut off after 300 s. A test recomputes the budget from the steps (round 4). - **The pin can move before the PyPI upload** (r4171232529, at `74b87375`): both publish jobs run the build job's pin step again, right before their upload (round 5, `994a39d7`). ## Copilot's findings at `5cdb2ded` - **Requirement markers were dropped before the comparison** (r4173500546): markers are now compared whole, removing only setuptools' extra clause, and direct URLs are compared too (round 6, `d7e9ff7b`, `d91fd5b9`). - **`skip-existing` on PyPI could make a mixed release** (r4173500563): a preflight before each upload refuses a file the build job did not verify, and a verified name at another digest, before anything is uploaded (round 6). ## Copilot's findings at `d91fd5b9` and `960d549b` - **The upload step had no bound of its own** (r4173861533), **and the test assumed one** (r4173861563). Every step after the build now declares its timeout, and the test reads each bound from the workflow (round 7, `960d549b`). - **The dry run could install an `opendox` that PyPI serves** (r4173890172). The install line stays as written and adds `--report`. The report must show the verified TestPyPI wheel before anything from the install runs (round 8, `b75205d4`). ## Copilot's review at `b75205d4` "Needs a closer look", with no findings and one item it had missed before: the dry run's comment said its job holds no token, but its checkout uses the job's `GITHUB_TOKEN`. The comment now says only the install step receives no token (`f59e7105`). ## The refresh of 2026-10-04: `main` `ca9e1bd5` merged, the head is `7ee2b425` This PR stays DRAFT, and it lands last among phase 3's shipped-package changes. The refresh leaves only a final `main` merge for later. - **The merges.** `main` `c4b55cc4` was merged at `d5dc825a`: #72 (T073), #77 (T084), #80 (T103), #81 (T102) and #85 (the T102 follow-on). `main` `ca9e1bd5` was merged at `7ee2b425`: #76 (T082). Both merges were clean. None of them touches `pyproject.toml`, the lock or `release.yml`. #76 re-pins `validate.yml`'s floors, and this PR's added cases only raise the counts above them; it adds no skip. - **The whole suite at `7ee2b425`**, in a venv with the `test` extra, the bundled PostgreSQL included: - `tests/`: `3286 passed, 11 skipped`; - `tests_runtime/`: `632 passed, 166 skipped`. actionlint and zizmor (pedantic) report no findings. - **The dry-run path, as far as it runs without publishing.** The tree is the release itself: this head merged with openDox-code#79 at its refreshed head `31361c91`, so `version = "0.1.0"` comes from #79 and not from a local edit. It is a local merge, never pushed. - **The `build` job** passes every run step: 42 of 42 web files, 119 of 119 source files, 2 of 2 migrations, the `local` extra's `opendox[runtime]` and `pixeltable-pgserver<0.7,>=0.6.0`, 34 distributions installed and satisfied, `initdb` and `postgres` 16.14, and `generate-and-open --help` naming `--local`. The installed `opendox --help` now prints `usage: opendox`, since T084 landed. The digests are `wheel-sha256=4f1a6969…` and `sdist-sha256=d0bf9fbd…`. The harness skips the release-commit and environment steps, which need the root pin and the environments, and it refuses to run the publish action. - **The publish jobs' read-only steps, live:** - the digest check passes on that artifact; - the preflight reads `TestPyPI holds no file of opendox 0.1.0 yet` and `PyPI holds no file of opendox 0.1.0 yet`, both reads of the public JSON APIs; - the pin step passes for `047bb4fa`, which the openDox root pins today, and refuses the trial's own commit with `a release publishes only the pinned commit`. The approval step was not run, because it needs the environments, which are Brett's setup. - **Nothing was published or dispatched.** ## Copilot's finding at `f59e7105` - **The reviewer check goes stale before the uploads** (r4174341950). Each publish job re-reads its environment's rule and this run's review history, right before its preflight, pin check and upload (round 9, `b3907858`). ## The final merge of 2026-10-05: `main` `651c35fe` merged at `e5f7f554` - **The merge.** `main` `651c35fe` was merged at `e5f7f554`: #82 (T100, 16.3a), #84 (T104) and #86 (the T100 follow-on). It was clean, as a local rehearsal had shown before #86 landed. None of the three touches `pyproject.toml`, the lock, `README.md`, `release.yml` or the release tests. This PR touches neither `validate.yml` nor the web-boundary census, so the floors stay `main`'s. - **What the three change in the release path: nothing that needs an edit here.** - #82's `doxbench_trust.py` and #84's `console_access.py` are new source files. The artifact checks count the tracked tree, so both are in the wheel with no edit (121 of 121). - The fresh-venv and TestPyPI install steps run only `--help` and never start the server. So #84's console copy under `OPENDOX_STATE_DIR` and #86's trust store are never written in CI. - **The whole suite at `e5f7f554`**, in a venv editable from that head's own tree, with the `test` extra, the bundled PostgreSQL included: - `tests/`: `3984 passed, 11 skipped`; - `tests_runtime/`: `632 passed, 166 skipped`. The venv matters. The subprocess tests import whatever tree the venv's editable install names, so a venv editable from another checkout tests that checkout instead. ## Copilot's findings at `e5f7f554`, and round 10 (`7fc0c46e`) Three findings, each ruled by the holder: - **The digests were recorded after the smoke test** (r4182777667, fixed). The smoke test installs and runs third-party wheels, which the lock pins by version but not by hash. A dependency's code could rewrite `dist/` between the artifact checks and the recording, and the digests would then bless the new bytes. The digests are now recorded right after `verify the artifacts`, and a new step after the smoke test requires `dist/` to still hold exactly those bytes. The publish jobs' digest checks are unchanged. - **The reviewer rule and the approval are not correlated** (r4182777733, declined, by design). The step refuses a deployment that waited on nobody. GitHub checks an approval against the rule in force when it is given. Only a repository admin can change the rule while the run waits, and that admin can edit this workflow too. The API offers no snapshot of the rule at the time of the approval. - **A yanked file** (r4182777770, fixed). The preflight refuses a held file that is yanked, and the check after each upload refuses a yanked file at once. Both name the remedy: un-yank it on the index, or release a new version. Each script is still one script for both indexes. Both JSON APIs carry `yanked` on each file, as read from pypi.org and test.pypi.org. Verification at `7fc0c46e`: - `tests/test_release_workflow.py`: `119 passed`. actionlint and zizmor (pedantic) report no findings. - **Mutants**, each run against the new tests: | mutant | result | |---|---| | the digest step moved back after the smoke test | `1 failed` | | no re-check after the smoke test | `5 failed` | | a re-check that compares names only | `1 failed, 3 passed` | | the preflight ignores `yanked` | `4 failed, 12 passed` | | the check after the upload ignores `yanked` | `2 failed, 10 passed` | - **The whole suite at `7fc0c46e`**, in a venv editable from this head's own tree: - `tests/`: `3995 passed, 11 skipped`; - `tests_runtime/`: `632 passed, 166 skipped`. - **The dry-run path, as far as it runs without publishing.** The tree is this head merged with openDox-code#79's head `9519cd8e` (`8bd254b2`, local, never pushed), so `version = "0.1.0"` comes from #79. - **The `build` job** passes every run step, the new re-check included (`dist/opendox-0.1.0-py3-none-any.whl: OK`). It reads 42 of 42 web files, 121 of 121 source files and 2 of 2 migrations. The `local` extra installs 34 distributions, all satisfied, and `initdb` and `postgres` 16.14 run. `opendox --help` prints `usage: opendox`. The digests are `wheel-sha256=70b98749…` and `sdist-sha256=830b0bb1…`. The harness now resolves `steps.<id>.outputs`, which the re-check's `env` reads. - **The publish jobs' read-only steps, live**, extracted from this head: - the digest check passes; - the preflight, with its new `yanked` check, reads `TestPyPI holds no file of opendox 0.1.0 yet` and `PyPI holds no file of opendox 0.1.0 yet`; - the pin step passes for `047bb4fa` and refuses `8bd254b2`. The approval step was not run, because it needs Brett's environments. - **Copilot at `7fc0c46e`**: "Needs a closer look", with no findings. Its note is that the first publication depends on the environments and trusted publishers configured outside the repository. That is Brett's one-time setup, listed below. - **Nothing was published or dispatched.** ## Lane 3's review D8 at `7fc0c46e`, and round 11 (`93d13217`) Lane 3 reviewed this PR independently at `7fc0c46e` and reported four findings. The holder ruled them in openxFactory#656 comment `5992918154`. - **F1, fixed: a tag named `main` in the openDox root could name the release commit.** - The cause: the three root fetches named a bare `main`, and git resolves that on the remote as `refs/tags/main` before `refs/heads/main`. A tag needs no review. - The fix: all three fetches now name `refs/heads/main`, and so does this repository's compare. The compare API accepts that form, as read live. - The test: a new case builds a stand-in root whose tag `main`, at a commit off the branch, pins another commit. In all three jobs, the branch's pin passes and the tag's pin refuses. Lane 3's own probe (`probe_tag_named_main.py`), run on this head, gives the same six verdicts. - **F2, fixed: which ref may deploy was optional, and nothing checked it.** - The cause: a dispatch runs the dispatched ref's own `release.yml`, and PyPI's trusted publisher matches the repository, the workflow's file name and the environment, never the ref. Each environment's deployment limit is the one server-side control over the ref. - The environments, done: the holder set both on 2026-10-05. As read live, `testpypi` and `pypi` each name the required reviewer `brettheap`, with `deployment_branch_policy` `{"custom_branch_policies": true, "protected_branches": false}` and one policy, the tag pattern `v*`. - The workflow: the build job's environment step, and each publish job's use-time step, refuse when the environment has NO CUSTOM DEPLOYMENT LIMIT, that is, unless its `deployment_branch_policy` is set with `custom_branch_policies`. - **The tag-only limit is REQUIRED, and configured.** Its pattern, `v*`, is verified through the API (above), but the workflow does not re-read it (round 12, below). The release is dispatched on the tag `v0.1.0`, created at T087's pinned commit. A dispatch on `main` cannot deploy. - **The remaining limit, accepted:** while sessions act as brettheap, the approval click is the last line of defense. A ruleset over `v*` tags is recommended to Brett, not required. - **F3, an accepted limit: the SHA-pinned publish action runs its image by registry tag.** The workflow's header now says so (see the actions table above). The follow-on is a hash-locked `twine upload` with the manual OIDC token exchange. - **F4, an accepted limit, with a runbook line now: the sdist is not byte-reproducible.** - Proven again here: two builds of the same trial commit (`c86c31d0`), from two clones, give the same wheel (`ead0fae3…`) but two sdists (`876165d9…` and `cad6a569…`). Those two sdists unpack to identical trees, and differ in the gzip header's time and the members' times. - So after the TestPyPI upload, only "Re-run failed jobs" may be used, never "Re-run all jobs" and never a second dispatch: a rebuild would record new digests, and the preflight would then refuse 0.1.0 for good. The header and the cut steps below say so. - The follow-on is a reproducible sdist. Verification at `93d13217`: - `tests/test_release_workflow.py`: `129 passed`. actionlint (every workflow) and zizmor (pedantic) report no findings. - **Mutants**, each run against the new tests: | mutant | result | |---|---| | the three root fetches name a bare `main` | `7 failed` | | the compare names a bare `main` | `1 failed, 10 passed` | | the build's environment step ignores the deployment limit | `2 failed, 3 passed` | | the build's environment step takes protected branches for a limit | `1 failed, 4 passed` | | each publish job's step ignores the deployment limit | `2 failed, 14 passed` | - **The whole suite at `93d13217`**, in a venv editable from this head's own tree: - `tests/`: `4005 passed, 11 skipped`; - `tests_runtime/`: `632 passed, 166 skipped`. - **The dry-run path, as far as it runs without publishing.** The tree is this head merged with openDox-code#79's head `9519cd8e` (`c86c31d0`, local, never pushed). - **The `build` job** passes every run step: 42 of 42 web files, 121 of 121 source files, 2 of 2 migrations, the `local` extra (34 distributions), `initdb` and `postgres` 16.14, `usage: opendox`, and the re-check of `dist/`. The digests are `wheel-sha256=ead0fae3…` and `sdist-sha256=876165d9…`. - **Read-only steps, run live:** - the digest check passes; - TestPyPI and PyPI hold no file of opendox 0.1.0; - the pin step, which now fetches `refs/heads/main`, passes for `047bb4fa` and refuses `c86c31d0`; - the ref step, as a dispatch on `refs/tags/v0.1.0` at `047bb4fa`, reads `is on main (ahead)` through the compare against `refs/heads/main`; - **the environment step**, now that the environments exist, reads `testpypi: 1 required reviewer(s), and only the refs its deployment limit names can deploy`, and the same for `pypi`. It ran with this session's own `gh` login, as a read only. The use-time approval step was not run, since it needs a run awaiting approval. - **Nothing was published or dispatched.** ## Copilot's finding at `93d13217`, and round 12 (`07cc8f36`, text only) - **The check proves a custom limit exists, not which pattern it names** (r4183379111). Copilot suggested reading each environment's `deployment-branch-policies` and requiring exactly the tag `v*`. The holder ruled it an accepted limit, with a text fix only (openxFactory#656 comment `5993462741`). - Why not read the patterns: the release would then depend on the job's `actions: read` token reading that endpoint, which is unverified. A refusal there would only show at the cut, after T087 has pinned the commit, and fixing it would need a new pinned commit. - The reasoning that declined r4182777733 applies: changing the pattern is an admin act, and an admin can edit this workflow too. - **Round 12 changes comments only.** The header now says the build job refuses when either environment has no custom deployment limit, and each publish job checks that again. It says the pattern, the tag `v*` alone, is configured (by the lane, 2026-10-05) and verified through the API, but not re-read by the workflow. The build job's environment step says the same. - The parsed workflow is identical to `93d13217`'s. - `tests/test_release_workflow.py`: `129 passed`. With `tests/test_triple_pin.py` and `tests/test_web_boundary.py`: `148 passed`. actionlint and zizmor (pedantic) report no findings. - r4183379111 is replied to and resolved. ## What Brett configures, once, before the first dispatch 1. **pypi.org.** Under Publishing, add a pending publisher (GitHub). PyPI project name: `opendox`. Owner: `opensoft`. Repository name: `openDox-code`. Workflow name: `release.yml`. Environment name: `pypi`. 2. **test.pypi.org** (its own account). The same pending publisher, with environment name `testpypi`. 3. **GitHub, opensoft/openDox-code, Settings, Environments: DONE** (by the holder, 2026-10-05, with `gh`). `pypi` and `testpypi` each name the required reviewer `brettheap`, with "Prevent self-review" off. **Each is limited to deployments from the tag pattern `v*` alone**, verified through the API. **A custom deployment limit is REQUIRED**: the build job and each publish job refuse an environment that has none. The workflow does not re-read the pattern itself, since changing it is an admin act (accepted, RULED `5993462741`). A dispatch on `main` or any branch cannot deploy. 4. **Recommended, not required:** a ruleset over `v*` tags. No ruleset covers tags today, so anyone who can push to this repository can create a `v*` tag. While sessions act as brettheap, the approval click is the last line of defense. A pending publisher does not reserve the project name, and `opendox` is free on both indexes today, so the first publish should follow the configuration closely. **At the cut**, after T087 has pinned the version bump, T089 has passed and AT-R1 has passed (T095, T096), on Brett's publish word: 1. Dispatch `release` with `version` = `0.1.0` **on the tag `v0.1.0`**, which the holder creates at the commit T087 pinned. A dispatch on `main` builds, but cannot deploy. 2. Approve `testpypi`. 3. When `testpypi-install` passes, approve `pypi`. 4. **After the TestPyPI upload, re-run only with "Re-run failed jobs", never "Re-run all jobs"**, and never dispatch the same version again. The sdist is not byte-reproducible, so a rebuild would record new digests, and the index keeps the first files for good (D8, F4). A job that fails after an upload can be re-run from the same run. It reuses its artifacts, and `skip-existing` uploads only what is missing. "Re-run all jobs" or a second dispatch of the same version rebuilds. The wheel comes out byte-identical, but the sdist does not, and TestPyPI keeps the first files under the same names, so every later preflight refuses, correctly, and 0.1.0 could not complete without a version bump and a new T087 pin. After the publish verifies, T099's last step is a small openDox root PR that replaces the root README's "Where `opendox` comes from" paragraph with the PyPI install line (openxFactory#1220). ## Version `pyproject.toml`'s `version` is `0.0.0` here. Release 1's version is **0.1.0**, RULED `5963162921`, *"0.1.0 (Recommended)"*: the first public release, with the API still pre-1.0. The bump is openDox-code#79. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability **T099's release step**: `pyproject.toml`'s `version` goes from `0.0.0` to **`0.1.0`**. This is RULED `5963162921` (Brett Heap, 2026-10-02): *"0.1.0 (Recommended)"*. openDox's first public PyPI release (T099, RULED `5962754358` item 1) carries version 0.1.0, as the first public release, with the API still pre-1.0. **It is the last phase-3 openDox-code landing.** The order, as `5963162921` sets it: 1. Every other phase-3 openDox-code landing has landed, including openDox-code#78 (T099's release workflow), #69 (T072) and #73 (T075). 2. This PR lands last. 3. The openDox root pins this commit (T087). 4. At the cut, after T089, `release.yml` is dispatched with `version` = `0.1.0`. It publishes only the commit the root's `contracts/code-pin.yaml` names, and refuses any other. Ready for the holder, after the final `main` merge. The title's DRAFT prefix is dropped. The holder posts READY, and the landers merge this PR after openDox-code#78, merging `main` into it again first if it needs to. The version line stays exactly `0.1.0`: T087 pins this PR's landing commit, and T099 publishes that commit. - **One line moves, with its comment.** `readme = "README.md"` rides in #78, not here. - **This PR merges cleanly with #78.** A local, never-pushed merge of #78's head `7fc0c46e` with this head `9519cd8e` auto-merged `pyproject.toml` (as `7ee2b425` with `31361c91` did before). The `[project]` table then reads `version = "0.1.0"` and keeps #78's `readme` line. - **Nothing in this repository reads the package's version.** `0.0.0` appears elsewhere only as the runtime container image's placeholder tag (`deploy/kubernetes/`, held by `tests_runtime/test_deploy_shape.py`), which is a separate value and does not move. ## The final merge of 2026-10-05: `main` `651c35fe` merged, the head is `9519cd8e` - **The merge.** `main` `651c35fe` was merged at `9519cd8e`: #82 (T100, 16.3a), #84 (T104) and #86 (the T100 follow-on). It was clean. Against `main`, this PR's diff is still `pyproject.toml` alone: `version = "0.1.0"` and its comment. - **The whole suite at `9519cd8e`**, in a venv editable from this head's own tree, with the `test` extra, the bundled PostgreSQL included: - `tests/`: `3876 passed, 11 skipped`; - `tests_runtime/`: `632 passed, 166 skipped`. - **A correction to the refresh below.** Its suite at `31361c91` ran in a venv editable from #78's checkout, and the subprocess tests import whatever tree that install names. So those children ran #78's tree, whose `src/` was the same `main` `ca9e1bd5`. Only this PR's version line went untested by them. This run uses a venv editable from this head. - **The release, built from this version.** #78's `build` job ran on #78's head `7fc0c46e` merged with this head (`8bd254b2`, local, never pushed), so `version = "0.1.0"` comes from here. It passed every run step, the new re-check of `dist/` included. The wheel built was `opendox-0.1.0-py3-none-any.whl` (`70b98749…`). #78's body has the details. - **Copilot at `9519cd8e`**: "Approval recommended", with no findings. - **Nothing was published or dispatched.** ## The refresh of 2026-10-04: `main` `ca9e1bd5` merged, the head is `31361c91` This PR stays DRAFT, and it lands last. The refresh leaves only a final `main` merge for later. - **The merges.** `main` was merged twice: `c4b55cc4` (#72, #77, #80, #81, #85), then `ca9e1bd5` (#76). Both merges were clean. Against `main`, this PR's diff is still `pyproject.toml` alone: the version line and its comment. - **The whole suite at `31361c91`**, in a venv with the `test` extra, the bundled PostgreSQL included: - `tests/`: `3178 passed, 11 skipped`; - `tests_runtime/`: `632 passed, 166 skipped`. - **The release, built from this version.** #78's `build` job ran on #78's head merged with this head, so `version = "0.1.0"` comes from here. It passed every run step: the version gate, `twine check --strict`, every artifact check, and the fresh-venv install and run. The wheel built was `opendox-0.1.0-py3-none-any.whl`. #78's body has the details. - **Nothing was published or dispatched.** 🤖 Generated with [Claude Code](https://claude.com/claude-code) Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…1238) Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Authored by lane openXfactory-3, delegated by lane openxfactory-4 (#656 5984809456) **Plan 034 T089, phase 3's checkpoint, and T092's phase-3 notes.** This PR runs every check T089 names, at the commits phase 3 pinned, and quotes each one. It also carries T092's phase-3 notes in `docs/opendox-carve-manifest.yaml`, on the holder's decision below. - Every check passes. F5.2 whole passes after T086's repair, so **F5.2's box closes here** (RULED `5962785556`, item 1: *"F5.2's box closes at phase 3's checkpoint (T089)"*), and F4.1's box closes here too (plan 034's box map: `F4.1 → T089`). T097 ticks both in #1144; this PR ticks only T089. - This PR is bookkeeping, so it carries no `Arc:` trailer (R1Q20 (a), `5817152735`; T091). Claims: #656 comments `5984809456` (lane openXfactory-3's D3: T098 and T089) and `5987202231` (D5: T092's phase-3 notes), delegated by lane openxfactory-4. Ruled, as T089 cites: F5.2's close, `5962785556`, item 1. The amended forms run: batch H (F10.1, F13.1), batches M and P (F16.1), batches C, F, G and K (F5.2), batch A (F11.1's guard). **No Rule 6 window.** This PR touches nothing under `openspec/changes/`: the checkpoint finds no record in #1144 that is wrong, so #1144's `tasks.md` is untouched. The record is [`specs/034-opendox-standalone-operation/evidence/checkpoint-phase3.md`](specs/034-opendox-standalone-operation/evidence/checkpoint-phase3.md). - Each falsifier in it was extracted by anchor, byte for byte, from #1144's `tasks.md` at `36908480` (sha256 `48db295594a6…`), and every extraction reproduces the sha256 the earlier records quote (F4.1 `8900b985e72f`; F10.1 `d94265449831` → `2358bd8f76ab`; F13.1 `1e1f7da5564a` → `0a47fa31f5e0`; F16.1 `fe22ec4873d1` → `619b32a955e2`; F5.2 `808619e066fa` → `4b1512c26814`; F11.1 `60beede1244b`). - Each ran from a fresh detached worktree at the pinned commit, with an empty `git status --porcelain --ignored`, in a scrubbed `env -i` environment, in the foreground of its wrapper. ## Verdict | # | T089's check | where | result, quoted | |---|---|---|---| | 1 | F4.1 | openDox-code `dede32b4` | **PASS**, exit 0: `1 passed` twice, then `no deferred reach names the consumer or the publisher` | | 2 | F10.1, as batch H amends it | openDox-code `dede32b4`, `pip install ".[local]"` (`opendox-0.1.0`) | **PASS**, exit 0: the bundled database, migrations `['0001', '0002']`; `validation: opendox-snapshot: 0 violations`; `serving http://127.0.0.1:8080/index.html` | | 3 | F13.1, as batch H amends it | openDox-code `dede32b4` | **PASS**, exit 0: the bundled database, migrations `['0001', '0002']`; every assertion holds, the last `grep -q OPENDOX_OIDC_ISSUER …/default.err` | | 4 | F16.1, as batches M and P amend it, with T100's named test | openDox-code `dede32b4` | **PASS**, exit 0: `24 passed` (16.6), the dialect line, `no model configured: the catalog offers nothing`, `83 passed` (`tests/test_chat_model_configuration.py`), `532 passed` (`tests/test_model_binding_trust.py`) | | 5 | F5.2 whole, as batches C, F, G and K amend it, after T086's repair | openXdox-code `56e1c238`, openDox-code `dede32b4` installed over it, `OPENXFACTORY` at `36908480` | **PASS**, exit 0: the seven suites pass whole (`45`, `23`, `18`, `6`, `40`, `9` and `12 passed`), `test -s` holds, and the `--chains` step prints `ok: 5 protected edit(s), each entered and holding` | | 6 | T098's interim F11.1 | openxFactory, `ARC_TIP` at T094's landing `36908480` | **PASS**: quoted from [`f11.1-phase3.txt`](specs/034-opendox-standalone-operation/evidence/f11.1-phase3.txt) (#1237 → `20ce593e`), and re-run here with the same line, `requirement 1 holds: 0 note(s) annotated, every other path a declared surface (11.1)` | ## T092's phase-3 notes, carried here **Where they ride, and which reaches they cover.** - **Phase 2's decision (b)**, recorded in T064's PR (#1215): phase 2's notes rode in T063's checkpoint PR, as phase 1's rode in T049's. - **The holder's decision for phase 3** (lane openxfactory-4's round-2 delegation, 2026-10-05, D5, recorded there and here): *"Phase 3's notes ride in phase 3's checkpoint PR, the T089 PR, not in T094's arc PR. This follows phases 1 and 2, whose notes rode in T049's and T063's checkpoint PRs on the holder's decisions (T092's text names both). The checkpoint PR carries no `Arc:` trailer, so F11.1's count of annotated notes never includes them. Run F11.1's content rule over them in that PR, as T049 and T063 did."* - **The holder's ruling (A)** (`#656` comment `5994463071`, 2026-10-05), on lane openXfactory-3's question whether `consumer_reach.py`'s last stand-ins take notes too: *"Phase 3 reads T092 as phase 2 did. T092 asks for one `edits[].note` per closed reach. Phase 2's T055 removed ten stand-in names of the same kind, and T063's checkpoint PR noted none of them (decision (b), recorded in #1215). A stand-in's removal is therefore not counted as a closed reach. Phase 3's notes cover the reaches T084 and T086 closed, the three rows of the default patch."* 3 notes on 3 rows (2 added, 1 extended), for the eleven reaches phase 3 closed. They are #1144's 4.3 reaches into openXdox that F4.1's scan still named at phase 2's pin `047bb4fa`, and the three rows of openXdox-code's ratchet `OPENDOX_BACK_IMPORTS`. All eleven drop at T084 (opensoft/openDox-code#77 → `e49b17c3`), which also retires `consumer_reach.py`. | entry | carve lines | reaches at `1e4a57fb` | note | |---|---|---|---| | `branch_session.py` `edits[0]` | 1574, 1992 | `:1587`, `:2005` | extended (phase 2's note left these two open for phase 3) | | `serve_project.py` `edits[0]` | 246, 247 | `:246`, `:247` | added | | `serve_workbench.py` `edits[0]` | 346, 406, 407, 543, 1214, 1664, 2606 | `:347`, `:407`, `:408`, `:544`, `:1215`, `:1665`, `:2607` | added | Each note cites the landings that close its reaches, each re-verified at this PR's run (`closers-p3.py`, quoted in the record): T084 (opensoft/openDox-code#77 → `e49b17c3`), T086 (opensoft/openXdox-code#37 → `56e1c238`: `openxdox.column_contributions.register()`, and `OPENDOX_BACK_IMPORTS` at `(0, 0)` with its pin at T087's commit `dede32b4`), and T094 (#1236 → `36908480`: the host's call). So the stand-ins `consumer_reach.py` still held at `047bb4fa` (`gate_console`, `serve_gate`, `serve_projection` and the two late bases) take no note, on ruling (A). F11.1's content rule, T049's script byte for byte, over `main`'s manifest and this PR's: ``` extended: scripts/ideation_dashboard/branch_session.py edits[0] added: scripts/ideation_dashboard/serve_project.py edits[0] added: scripts/ideation_dashboard/serve_workbench.py edits[0] F11.1's manifest content rule holds: 3 note(s) annotated, nothing else in the manifest moved ``` With every `edits[].note` removed, the two manifests are equal, and no other line moved. The note check, the reach mapping, `scripts/validate-carve-manifest.py` (`OK`) and F4.1's scan walk are quoted in the record's last section. ## The pins From `pins.sh` over openxFactory at `36908480`, with `openDox` and `openXdox` initialized recursively: openDox root `e1e3a3c3` (`code` → openDox-code `dede32b4`, T087's pin), openXdox root `9564d5d9` (`code` → openXdox-code `56e1c238`, T086's landing; its `contracts/opendox-pin.yaml` → `e1e3a3c3`), and openXdox-code's `pyproject.toml` pins openDox-code `dede32b4`. All six T090 equalities hold, and the runs used exactly the pinned commits. ## What changed (5 files) - `specs/034-opendox-standalone-operation/evidence/checkpoint-phase3.md`: new, `Status: record`. It is linked from the feature 034 entry in `README.md`, beside T098's F11.1 record, and from `plan.md`'s evidence tree. - `specs/034-opendox-standalone-operation/tasks.md`: T089 ticked, with its Run bullet (T063's form), and T092's entry gains phase 3's sentence after phase 2's (#1218's form; T092 stays unticked, since 11.1 is ticked at ARC close). The box map already names T089 for F4.1 and F5.2, and nothing moves, so it is unchanged. - `specs/034-opendox-standalone-operation/plan.md`: the evidence tree and its prose. - `README.md`: the checkpoint link. - `docs/opendox-carve-manifest.yaml`: T092's phase-3 notes, `note:` lines only (2 added, 1 extended). With every note removed the manifest is unchanged. ## Validation, at the head against `main` `20ce593e` - **`python3 scripts/validate-openspec-cli-pin.py --all --strict`** (pinned CLI 1.12.0): rc 0, `Totals: 110 passed, 1 failed (111 items)`, `0 UNDISPOSITIONED failures`; the one failure is the accepted `add-chain-attestation` exception, as on `main`. - **doc-health `--single-repo . --as-of 2026-10-05`**, run at `main` `20ce593e` and at the head in two worktrees of one clone, each named `openxFactory`, so like is compared with like: rc 0 at both, `Findings: 31 critical, 26 error, 69 warning, 20 info. New regressions vs previous report: 0.`, and the two reports are byte-identical, a delta of zero. - Every 8-character commit sha in the record and in this diff resolves with `git rev-parse --verify` in its own repository (openxFactory, openDox-code, openXdox-code and the two roots). The two `spec` shas in the record's pin table, `f7ee3c76` and `f088b097`, are the roots' gitlinks, read with `git ls-tree`. ## Rehearsal (2026-10-04, at that day's mains, before T086, T087, T094 and T098) At openDox-code `38d3350e`, openXdox-code `6a3b93b9` and openxFactory `c8d80020` (ARC_TIP `fcb45380`): F4.1, F10.1, F13.1, F16.1 (`24`, `83` and `135 passed`) and F11.1 passed; F5.2 was red as expected until T086, on `tests/test_session_snapshot.py` (`17 failed, 6 passed`: 16 `SeamNotRegistered` at openDox's kickoff seam, 1 `IndexError`) and `tests/test_snapshot_validation_launch.py` (`9 failed`: T070's install-mode precondition, hosted by default and refused for no `OPENDOX_OIDC_ISSUER`), with the other five suites whole and `--chains` at `ok: 4 protected edit(s)`. opensoft/openXdox-code#37's scope covers all 26. Host paths in the quotes are written `<workdir>`, and the user `<user>`. No closing keyword appears in this body or in any commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Lane: openxfactory-4 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…h no database service (plan 034) (#75) Plan 034, task **T095**, slice `acceptance` (AT-R1, the HTTP half), phase 3. The plan is `specs/034-opendox-standalone-operation/tasks.md` at opensoft/openxFactory `main` `596a9a90`, § "Acceptance: AT-R1", as T007 batch N (`bdd0f586`) amends it. It realizes **FR-011's HTTP half**. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) - **Claim:** openxFactory#656 comment [`5961416730`](https://github.com/opensoft/openxFactory/issues/656#issuecomment-5961416730). - **Rulings:** - [`5961364221`](https://github.com/opensoft/openxFactory/issues/656#issuecomment-5961364221) item 2: T095 is drafted now, and does not go READY before T063 lands. - [`5850003126`](https://github.com/opensoft/openxFactory/issues/656#issuecomment-5850003126): R1Q10 (a), R1Q12 (a), R1Q13 (a) with (c), R1Q15 (b), and R1Q16 (iii) and (iv). - [`5963851934`](https://github.com/opensoft/openxFactory/issues/656#issuecomment-5963851934): the console token travels in the opened URL, and the harness reads the private copy. - [`5973854291`](https://github.com/opensoft/openxFactory/issues/656#issuecomment-5973854291), F1 (i): the chat rail reads a thread only with a branch session (openDox-code#85). - **State: DRAFT.** Its `After` line is T089, T076, T104 and T007 batch N. Batch N (openxFactory `bdd0f586`), T104 (openDox-code#84, `32943cbf`) and T076 (openDox#17, `504324de`) have landed. Its READY waits only on T089, lane openXfactory-3's phase-3 checkpoint. The holder posts READY and the landers merge; this writer does neither. - **Release 1, for T096's evidence:** - **P** = `dede32b4b6f3d0f147d599776f83628c5af8ff3d`, the openDox-code commit T087 pins in the openDox root (`e1e3a3c3`). - **X** = this PR's landing commit, RELEASE1_TIP, which lane openXfactory-3's T096 evidence fills in. - **Build inputs: this PR changes none; it changes only CI and tests.** It touches `.github/workflows/validate.yml`, `acceptance/at_r1_http.py`, `tests/test_at_r1_http_harness.py` and `tests_runtime/test_deploy_shape.py`. It does not touch `pyproject.toml` or anything under `src/`. To check, both distributions were built locally the way `release.yml` builds them: the hash-locked release tools, `python -m build --no-isolation`, and `SOURCE_DATE_EPOCH` set to the commit's time. That was done at P and at `d29e68ca`. The two **wheels** hold the same 129 files, each with the same bytes. Built at P's `SOURCE_DATE_EPOCH`, `d29e68ca`'s wheel is byte-identical to P's (`sha256 8ecea00d…`). With each commit's own time they differ only in the archive's timestamps. The **sdist** at `d29e68ca` holds one file more, `tests/test_at_r1_http_harness.py`, because setuptools' default sdist takes `tests/test*.py`; its `SOURCES.txt` lists that file. So what installs at X is what installs at P, and the source archive differs by that one test file. - **Convergence:** the lexer's and the resolver's residual misreadings are accepted limits of the harness, under the holder's ruling. They are listed, with the checks showing that no shipped file uses them, in "Accepted limits" below. ## On this PR, `acceptance` and `validate` are both green The stack the harness drives has landed. `main` `dede32b4` (2026-10-05), which is P, carries: - #60 (T071), #65 (T088), #67 (T070) and #69 (T072, the bundled server and the `local` extra); - #71 (T085), #72 (T073), #73 (T075) and #74 (T081); - #64, #77 (T084) and #80 (T103); - #81 (T102), #85 (the T102 follow-on), #76 (T082) and #82 (T100); - **#84 (T104, the console token via the opened URL)**, which landed on 2026-10-05; - #86 (the T100 follow-on, the trust store's review findings), #78 (T099, the release workflow) and #79 (version 0.1.0). The harness reads the console token the way T104 delivers it (step 6 below). Until T104 landed, this PR's `acceptance` job failed **by design**, at `[a.capabilities carries no console token]`. This branch now merges `main` `dede32b4` (P), so its own job runs the harness against the delivery it was written for, #86's trust store included. At this head `d29e68ca` it printed `AT-R1 HTTP half: PASS (314 assertions held)` (run 37316440405, job 111784259300). The `validate` job runs the whole suite and is green: `triple: selected=5379 passed=5368 skipped=11 failures=0 errors=0` (run 37316440405, job 111784258794). **Making `acceptance` a required check is a ruleset change for the repository's owner** (`docs/branch-protection.md`). This PR does not touch the ruleset. ## What changes (four files, nothing else) | file | change | |---|---| | `acceptance/at_r1_http.py` | NEW. The harness. Standard library only. Not a pytest module: it sits outside `tests/` and `tests_runtime/`, so `testpaths` never collects it and #1144's F9.1 is unchanged (FR-006's "no exclusion" holds). | | `.github/workflows/validate.yml` | A new `acceptance` job, appended after the last line. It has **no services**, runs no pytest and installs nothing itself: one step, `python3 acceptance/at_r1_http.py`. The `validate` job and its triple are untouched. T082 (#76) re-pinned the triple's floors on `main` (3977 / 3966), and T100 (#82) changed only the `validate` job's comments; this PR merged both cleanly. | | `tests_runtime/test_deploy_shape.py` | Required, because `test_the_workflow_declares_the_required_job_and_its_steps` pinned `sorted(jobs) == ["validate"]`. It now admits `["acceptance", "validate"]`. A new case holds `acceptance` to no service or container, no pytest run, no `pip install` step, and one harness step outside `testpaths`. And `test_every_install_of_this_package_reads_one_dependency_lock` now also requires that the harness passes the lock to pip. | | `tests/test_at_r1_http_harness.py` | NEW. It tests the harness's DECISIONS in process, as `tests/test_smoke_signals.py` tests the browser half's oracle: it loads the harness from its file, against an in-process loopback server, so nothing is installed or started. **564 cases.** Every decision below has its cases, and 221 mutants of those decisions are each killed (below). | The two test changes add cases to SELECTED and PASSED, and none skips; the floors permit a rise, and no floor is edited. ## The documented command, and how the harness runs it T007 batch H's 10.3 addendum (`openspec/changes/add-neutral-product-standalone-operability/tasks.md`, 10.3, "AMENDED — T007 Batch H (`5850003126`; Ruled R1Q15 (b), R1Q16 (iii))"), verbatim: > The command the root's `README.md` documents is the standalone install and its one start: `pip install "opendox[local]"`, then `opendox generate-and-open --local …`. The flag selects the local mode explicitly (13.4 as this batch amends it), and the `local` extra carries the bundled server that mode starts (13.1 as this batch amends it). After the install, that start is the single command requirement 10's second scenario has a user run. The entry point is still the code leg's console script, and no `Makefile` target is added, for the reason above. Carried out by T070 and T076. The harness holds both lines as constants (`DOCUMENTED_INSTALL`, `DOCUMENTED_START`, the `…` being U+2026 after one space). Compared mechanically with T076's README (opensoft/openDox#17 at `7eef03b4`): ``` DOCUMENTED_INSTALL = 'pip install "opendox[local]"' (6f63616c5d22 tail bytes): README lines [32] DOCUMENTED_START = 'opendox generate-and-open --local …' (616c20e280a6 tail bytes): README lines [33] ``` - **The start line.** The harness runs `opendox generate-and-open --local --repo-root <repo> --repository fixture --no-open --port <free port>`. That is quickstart.md § 3's filled form, with `--port` given a port found free at run time and checked free on 127.0.0.1 and ::1 just before the start. Before it starts anything, it asserts that its argv begins with exactly the tokens of `DOCUMENTED_START` before the `…` (`start.documented-command`). The `…` is the verb's own arguments, as the README says, and every flag above is one the README names. `opendox` is the fresh venv's console script, resolved on the child's PATH and asserted to lie inside that venv; argv[0] stays the literal `opendox`. - **The install line.** No release of `opendox` is published (PyPI answers 404 for `opendox`; the release-channel question is with Brett). So the harness installs the same distribution with the same extra from the checkout: `pip install "<checkout>[local]"`, from a copy of this checkout's tracked files. That is exactly the substitution the README makes for its first line (`pip install "./code[local]"`, run from the root). It installs through `constraints-cpython312-linux.txt` where the lock's own interpreter runs it (CPython 3.12 on Linux, as in the job). The lock pins versions and adds no package, so the install is still `opendox[local]` and nothing else (AT-R1 step 2). ## What the harness asserts, in order (AT-R1 steps 1-4, and the route answers behind 5-8) Each assertion has an id. The run stops at the first that fails and prints `AT-R1 HTTP half: FAIL [<id>]: <why>`, then exits 1. `--keep-going` reports every failure instead, and still exits 1. A harness that breaks before a verdict exits 2, never 0. 1. **Install.** A fresh venv, then `opendox[local]` (R1Q16 (iii)). Then `install declares the local extra`, read from the installed distribution's metadata. 2. **The clean machine, ASSERTED** (`clean.*`), in a fresh `OPENDOX_STATE_DIR` created empty for the run: - none of `openxdox`, `ideation_dashboard`, `doc_health` and `corpus_adapter_openxfactory` is importable in the venv (`python -I`, from an empty directory); - `omp` is not on the PATH, nor is the product's own `doxbench_bridge.HARNESS_COMMAND`; - no identity broker (`openprofiler-broker`) is on the PATH, and no issuer setting is set; - nothing listens on 127.0.0.1:5432 or [::1]:5432; - no PostgreSQL Unix socket answers in a shared directory. The harness reads every listening `.s.PGSQL.*` socket the kernel lists in `/proc/net/unix`, plus the distribution sockets as a fallback; - no DSN or `PG*` setting reaches a child; - neither repository holds a binding at the product's own `doxbench_binding.DEFAULT_BINDINGS_RELPATH`; - no bundled-server process exists before the start. Every child runs with `GIT_*`, `XF_*`, `OPENDOX_*`, `PG*`, `DATABASE_URL` and `PYTHONPATH` dropped, a fresh `HOME`, and `TMPDIR` inside the scratch directory. Before anything is installed, the harness also refuses a `TMPDIR` that another user could change (exit 2, by T104's own rules for the state tree; step 6), so its verdict on that tree judges what the product made of it. 3. **Two plain repositories**, each a fresh `git init` (spec.md AT-R1 step 3): (a) `tests/fixtures/plain-documents`, and (b) quickstart.md § 2's three notes, byte for byte, with no front matter. Each gets `git config user.name`/`user.email`, which the served actor is read from. 4. **The documented start**, once per repository (`start.*`): the port is free, the argv matches, the console script lies in the venv, the server is ready within 120 s, and the process that answered is still the one it launched. 5. **Fetches.** - `/` must be HTML by its type's essence (`text/html`, parameters aside), with an `<html>` element. It must also be UTF-8, as the harness reads it: a UTF-16 BOM, or a `charset` naming anything but a UTF-8 label, in its `Content-Type` or anywhere in the page, fails `[x.http / is UTF-8]`. - `/snapshot.json` must be a JSON object, non-empty, every document an object with a non-empty string `path`, and neutral per F5.3: none of #1144's fourteen declared words (5.5's falsifier list, verbatim) in any string value. - The grouping station, named by `/capabilities`' own display facet, must hold a tile with an id and a member document, so a grouping tile can open the chat pane (R1Q13 (a) with (c); this fails, and does not skip). - `/capabilities` must give `install.mode == "local"`. As quickstart § 3 asserts it (T007 batch N), its **raw** payload carries the console token **by name nowhere**: `console_token` is in neither the raw text nor any key of the parsed payload, however deep or however escaped (`[x.capabilities carries no console token]`). The by-VALUE half runs once step 6 has the token. 6. **The console token, as the user's browser is handed it** (T104). The start prints `console <file URL>`, the PATH of a private opener, and never the token. The harness reads that opener itself, with the standard library, and imports nothing from the product. The checks: - the start printed the opener, at `<OPENDOX_STATE_DIR>/console/<port>.html`, outside the served repository; - it is private: this user's regular file, mode exactly 0600, one link, in `console/` of mode exactly 0700 (as quickstart § 3 checks it). Its whole path is judged by T104's own rules for that tree: the state directory is this user's, and no one else can write it; every directory above it, as written and as resolved, is this user's or root's, and sticky where others can write it; every symbolic link on the way is this user's or root's; - it is opened with `O_NOFOLLOW | O_NONBLOCK`, read only if `fstat` calls it a regular file, and read WHOLE: a file past 64 KiB is refused by name, never judged by a truncated prefix; - it is a page the harness reads as the browser does: UTF-8 by the same rule as `/`, and holding no SVG, MathML, `frameset`, or refresh or script inside a `select`. Otherwise it forwards nothing, by name; - it has exactly one LIVE meta-refresh, as a browser that runs scripts parses the page. None inside a `<template>`, a `<noscript>`, a raw-text or escapable raw-text element, a `select`, or after a `frameset` counts. The first of a repeated attribute is read, `http-equiv` is `refresh` exactly, and `/>` closes only a void element. A tag that holds a named character reference with no `;` is not followed: `html.unescape` decodes it, and a browser's attribute rule does not; - that refresh is one a browser follows (the HTML standard's declarative refresh steps). It goes to this plane's console page (`/` or `/index.html`), on a loopback host the launched plane answers on, with no backslash, whitespace, control character or user information. `#console_token=<token>` is in the FRAGMENT, and never in the query or the path, raw or in any percent-decoding; - that token is one the page accepts: `[A-Za-z0-9_-]{16,512}`, read as T104's `takeDeliveredConsoleToken` reads it (`URLSearchParams` over the fragment); - its JSON record (`<script type="application/json" id="opendox-console">`, kind `opendox-console-access` v1, live, parsed as `JSON.parse` parses it) describes that same forward: its port, its token, its `opened_url` and its `page_url`; - the PAGE that forward opens is asked as the browser asks it: of the forward's own host (in `Host`, which the plane's loopback gate reads), with its path and query, and without its fragment. It must answer 200 as HTML (`[x.console page is HTML]`) and be UTF-8 (`[x.console page is UTF-8]`). Steps 7 and 8 read that page and its origin, never `/` in its place. T104's forward is `/index.html` on `127.0.0.1`; - and, BY VALUE (T007 batch N): the token is nowhere in `/capabilities`' raw payload, nor in any key or string value of the parsed one, under whatever name (`[x.capabilities carries the opener's token nowhere]`). 7. **The model catalog.** `/workbench/model-catalog` must refuse a caller with no token (4xx). With the opener's token in `X-XF-Console-Token`, it must answer 200 in the envelope the chat rail adopts (`views/doxbench-chat-model.js`, `adoptCatalog`): - `schema_version` is any JSON number equal to 1, never `true` and never `"1"`; - `kind` is `workbench-model-catalog`; - `models` is an array with no entry the rail would offer: none whose `available` is exactly `true`, as the rail reads it (16.4). The harness also asserts that the served bundle names both this route and this kind, so neither constant can drift from the bundle. 8. **Every route the panes can request**, asked at the console page's origin, answers, and none answers 5xx or drops the connection (derivation below). And the chat rail reads no thread: it reads `/workbench/thread` only through a branch-session column (`gate.workbench.session`, openDox-code#85), and the plane must contribute none (`[x.chat rail reads no thread (no branch session)]`). 9. **Stop and look.** SIGTERM to the entry point alone, as `kill` would, and a wait of up to 90 s. Then: - the entry point must exit 0; - no live process whose executable lies in the venv's `pixeltable_pgserver` package, or whose command line names the fresh state directory (R1Q16 (iv)), checked at the operating system; - the opener must be gone; - the token must appear nowhere in either output stream of the whole serve. Cleanup runs however the run ends, but only AFTER the verdict is taken, so a leftover still fails the run. **No line the harness prints quotes a console token.** Every token the run has seen is blanked out of every id, reason, note and the report (`Verdict.redact`), as it is and inside any run of `%XX` escapes that decodes to it, partly encoded or encoded twice: the tokens the opener carries, delivered or refused, and its record's; and one `/capabilities` publishes, under its name at any depth. `serve_one` reads the opener's tokens as soon as the start answers, before step 5 quotes anything (T104 writes the opener before it serves). No reason quotes the server's own bytes either: not the entry point's output, a `/capabilities` payload, a catalog body or its envelope's values or an available entry's id, the snapshot's documents or its grouping field, the opener record's values, a `<base href>`, a peer's error text, a printed opener path other than the expected one, or a `Content-Type` that is not a plain MIME type. Every printed line escapes what is not printable (a control, a newline, a bidirectional override, a lone surrogate), so no text a server sends can forge a line of the log or break one. **Every parser reads as the browser reads, or refuses by name what it does not model:** - JSON is parsed as `response.json()` parses it: a BOM dropped, an invalid byte read as U+FFFD, and `NaN` and `Infinity` refused. A body nested past the parser's depth is a named failure, never exit 2. - JavaScript is lexed by its own whitespace (U+FEFF and every space separator) and line terminators, a hashbang line is a comment, and its strings are decoded with its escapes. An escape a module refuses is a named failure. - HTML is read with the first of a repeated attribute, and live elements only. A script is what its `type` and `language` make it. SVG, MathML, a `frameset`, a script, link or base in a `select`, and one whose attributes hold a named reference with no `;` (`©=2`, which `html.unescape` decodes and a browser does not) are refused by name. A page is read as UTF-8, and refused by name where a browser might read it otherwise. - URLs are resolved against the running server's origin. A reference a browser reads differently is refused by name: a backslash, a tab or newline, whitespace or a control at an end, user information, or a percent-encoded dot segment. So are a `<base>` other than `/` and an import map. - A string from JavaScript or JSON is read in Unicode scalar values, as a browser reads a URL from it: a surrogate pair joined, and a lone surrogate read as U+FFFD. - Request targets are percent-encoded as a browser encodes them. A `Content-Type` is parsed as the MIME Sniffing standard parses it, and one with more than one value is refused by name. A redirect is refused by name, because a browser follows it. ## How the route list is derived (from the served JS, not by hand) `derive_bundle` reads the bundle **from the running server**, resolving against its origin (`http://127.0.0.1:<port>`): 1. It fetches `/`, then follows the page's LIVE module scripts (`<script type="module" src>`, `type` and `language` read as the HTML standard reads them) and `rel~=stylesheet` links. Both resolve against the page's base URL: its `<base href>`, which must be `/`, or the page's own. A view module `/capabilities` declares is walked too, but it is no entry: - a page with no live module script is `[bundle.entry]`; - a classic script, by `src` or inline, is `[bundle.classic-script <src>]`, unless it is marked `nomodule`; - an inline module script is `[bundle.inline-module]`; - markup the harness does not model is `[bundle.unmodeled]`. 2. In every module, a small JS lexer strips comments and regular-expression literals, decodes JavaScript's string escapes, and yields each string literal, in Unicode scalar values, with the code before it. Every run of JavaScript whitespace, and every comment, reads as one space there, so no run is too long. A template's `${…}` is read as code, starting an expression, so an import or a route inside one is found. A `/` divides after an operand (a name, a literal, a call, a group, a postfix `++` or `--`), and opens a regular expression after a prefix operator or the `)` of an `if`, `while`, `for` or `with` condition. After a `}` it does either, by whether the `}` ends a block or an expression, which a lexer cannot tell: a module with a `/` right after a `}` is `[bundle.ambiguous-slash <path>]`. A keyword is read as one only where it is no member's name (`obj.return`, `obj?.of`) and no part of a longer or a private name (`ñreturn`, `this.#if`); after a spread's `...` or a decimal point (`1. in`) it still is. `of` is a keyword only as a `for` head's separator, after its binding or target (`for await (` opens a head too), and a name elsewhere, a classic loop's initializer, condition and update included; a `/` after a spread's `...`, a division's own `/`, `export default`, `extends`, or `break`, `continue` or `debugger` and a line break opens a regular expression. So does one after a line break that ends a statement by automatic semicolon insertion: before a `++` or `--` (then a prefix), and after a labelled `break` or `continue`, a declaration's lone binding, or a static import's specifier. That code tells an `import … from "x"`, an `export … from "x"` or an `import "x"` from an ordinary string, and from a method named `import` (`loader.import("x")`). A module holding an escape a module refuses (a legacy octal escape, `\8`, `\9`, or a malformed `\x` or `\u`, outside a tagged template) is `[bundle.syntax <path>]`. 3. It follows every static specifier, every literal dynamic `import("x")`, and every view-binding module `/capabilities` declares, transitively. Each module is fetched once, and judged once per way it is imported: - A static import must answer 200, even of a path another module imports dynamically. A failed one is a module-load `pageerror`, which AT-R1 step 8 forbids. - A dynamic import may be refused, but not with a 5xx. Standalone, `/views/intent-feed.js` answers 404 (10.2a, not owed), and its importer degrades. - A served module must be JavaScript by its type's essence, and a linked stylesheet `text/css`. A redirect is refused by name, never judged by its status. - A BARE specifier is `[bundle.bare <specifier>]`. A module or stylesheet from another origin (another host, port or scheme, `data:`) is `[bundle.external <url>]`, and is never fetched from loopback by its path. A same-origin absolute URL (`http://127.0.0.1:<port>/x.js`) is a path of this plane. 4. In every module of that graph, every string literal that is a same-origin path (`/` or `./` followed by a letter, not a `.js`/`.css` path) is a route the bundle can request. At the integration below, that is **38 modules and 18 routes**. A static read cannot tell a load-time request from an on-click one, so the harness requests ALL of them, a superset of the wheel's, the lens's and the chat rail's load-time reads. T096's browser run observes the load-time set itself. The nine `/actions/…` routes get a GET too; a GET there finds no handler (404), so it never executes an action. A prefix ending in `/` (`/source/`) is completed with each document the snapshot lists, plain and keyed by the workbench's key (`/source/fixture%40main/<doc>`), as the panes complete it. The thread read (`/workbench/thread`) is asked as its bare literal only, as every literal is. Since openDox-code#85, a standalone plane's rail sends no thread read at all, and the harness no longer completes it with the query it once claimed the rail sends (the holder's T096 dry run, F4). Every request carries the console token the opener delivered, as the doxBench transports do, so a guarded read answers from its handler and not from the console check. ## Accepted limits (the holder's convergence ruling) The harness reads the bundle with a lexer, not a parser. The holder ruled, under the rule of openxFactory#656 comment [`5988818366`](https://github.com/opensoft/openxFactory/issues/656#issuecomment-5988818366) (written for #86 and applied to this PR), that misreadings only a parser could fix are recorded as accepted limits of the acceptance harness, not chased round after round. A form is fixed now only if a shipped openDox web file contains it, or if the fix is one line with a test. The limits are recorded in `JsStrings`'s docstring and in `_resolve`'s. **The lexer** (`JsStrings`): - A `/` right after a `}` divides after an expression's `}` and opens a regular expression after a block's. Such a module is refused by name (`[bundle.ambiguous-slash <path>]`). - In one form, the `/` is read wrongly. After a declaration list's last binding with no initializer, then a line break (`let x, y` [line break] `/re/`), automatic semicolon insertion ends the declaration, so the `/` opens a regular expression. The lexer reads a division, and an import after it on that line can be missed. Telling that comma from an expression's comma takes knowing that it lies in a declaration. **URL resolution** (`_resolve`). `urllib.parse` reads a few references differently from the URL standard, and these are not refused: - `///a.js` and `http:///127.0.0.1:<port>/a.js`; - an empty path segment (`.//a.js`); - a dot segment or an empty path in an absolute same-origin URL; - an unencoded `^`; - a loopback host that the URL standard rewrites (`127.1`, `0x7f.0.0.1`, `2130706433`, `127.0.0.1.`), which is judged an external host. **No shipped file uses any of them.** These checks ran locally at `d29e68ca` over the 42 files the build packages (`web/**`, `web/**/.*`): 39 modules, the page, its sheet and `vendor/.gitkeep`. - The lexer against acorn 8.18's exact token stream (a full module parse): they agree on all 205 `/` decisions, 132 regular expressions and 73 divisions. They agree on all 82 static imports and export-froms. No module is ambiguous or malformed. No regular expression follows a name, a literal, a `)`, a `]` or a `}` (3 follow `return`), so the residual form, which needs a binding name and then a line break before the `/`, occurs nowhere. - The resolution against node's WHATWG `URL`: all 84 of the shipped modules' import specifiers, static and dynamic, resolve alike. **How the limits were found.** A differential check of the lexer against node's own module parser, the static module requests of a `vm.SourceTextModule`. It covers 196 contexts, each a regular expression or a division before a static import, with five kinds of separator: 1960 snippets, 978 of them valid modules. In those, the lexer hid the import in 195 at `ff04e015`, in 85 at `09d9d632`, and now in the 10 of the residual form. It has no false failure at any of these heads, and refuses the same 10 by name at each. A second check, of 81 contrived references against node's WHATWG `URL`, finds 13 differences: the 12 that the URL list above records, and `//a.js`, which is an external URL either way. The overview at `e4f81d48` named identifier-parsing errors; that class was a one-line fix with a test, so it is fixed in `d698c6ea`. ## Falsifier: the harness itself ### At this branch, `main` `32943cbf` plus the harness (what this PR's own `acceptance` job runs): PASS The job at `d29e68ca` (run 37316440405, job 111784259300): ``` AT-R1 HTTP half: PASS (314 assertions held) ``` Locally, the same tree, run as that job runs it (`python3 acceptance/at_r1_http.py` from the checkout's root, with no database service; `r83`, at `d29e68ca`, which merges `main` `dede32b4`): ``` AT-R1 HTTP half: PASS (314 assertions held) ``` Its log is line for line the same as `r82`'s (`d698c6ea`, before `dede32b4`) and `r76`'s (`ff04e015`, the first head with `main` `32943cbf`), apart from ports, process ids and scratch paths. That includes `[a.capabilities carries no console token]` and every console-opener check. The `validate` job's suite, run locally at `d29e68ca` with the job's install line and a PostgreSQL 16 standing in for its service, printed `triple: selected=5379 passed=5368 skipped=11 failures=0 errors=0`. So no change since then moved a module, a route or an assertion. Neither log holds a token-shaped run. The first green `acceptance` job ran at `d50e8cef`, once #84 had landed. Its merge ref with `main` `32943cbf` (`ff68275a`) gave `PASS (314 assertions held)`, and so did `r75`, locally, on a merge of the same two commits whose tree is identical to CI's. ### Before T104 landed: FAIL, named At `main` `38d3350e` plus the harness (`d50e8cef`, `r74`, `--keep-going`), the harness failed only where T104 was missing, in both repositories. In CI it stopped at the first of these: `1 failed, 34 held`. ``` == r74-r27-pr-tree-keepgoing rc=1 held=282 AT-R1 HTTP half: FAIL [a.capabilities carries no console token]: … also FAIL [a.console opener printed]: the start printed no `console <file URL>` line naming the opener, … also FAIL [a.catalog answers]: /workbench/model-catalog with no console token to present (step 6 found none) answers HTTP 403 (its body is not quoted) also FAIL [b.capabilities carries no console token]: … also FAIL [b.console opener printed]: … also FAIL [b.catalog answers]: … 6 failed, 282 held ``` No token-shaped string appears anywhere in that log, though that tree's `/capabilities` published the token. The expected red moved as the stack landed: - at `main` `047bb4fa` and `66ff7257`, it was `[install declares the local extra]`; - once #69 landed, it was `[a.capabilities install.mode == local]`; - from #72, it was the token delivery above, which only T104 supplies; - since #84 landed (`32943cbf`), it is green. ### Against T104 before it landed (local integrations, never pushed): PASS **#84 `fb8a1cc4` with `main` `38d3350e` merged in** (`fc9b39b5`, local only). #84 already merges `main` `ca9e1bd5`. The merge's one conflict was the web census's class-A total, resolved as base plus both deltas (18486 + 40 + 95 = 18621). With `b7b9b843`'s harness, `--keep-going` (`r71`): ``` == r71-r26-on-live15-t104-keepgoing rc=0 held=314 AT-R1 HTTP half: PASS (314 assertions held) ``` Each earlier head of this round passed at the integration of its day: - `r73` (`d50e8cef`'s harness) and `r69` (`0717f72f`'s): the same integration, 314 held; - `r67` (`3392f934`'s), `r65` (`9229c659`'s) and `r63` (`ba216f84`'s): the same integration, 310 held, before the console page checks; - `r61` (`4bdb41fb`'s) and `r59`: #84 `fb8a1cc4`, 306 held, before the UTF-8 and entry checks; - `r57` and `r58`: #84 `182cac76` plus `main` `ca9e1bd5`; - `r53`: #84 `d4b99436` plus `main` `c4b55cc4`, after #85; - `r49`: #84 `d4b99436`. **Verdict at `main` `32943cbf` with this branch: AT-R1's HTTP half passes, with 314 assertions held.** That covers: - the install, the clean machine and both repositories; - the documented start: ready, with the process the harness launched; - `/` as HTML, and UTF-8; - the neutral, non-empty snapshot with a grouping tile: repository (a) has 8 documents, and repository (b), with no front matter, has 3; - `install.mode == local`, and the token on `/capabilities` neither by name nor by value; - the opener: printed, at `<OPENDOX_STATE_DIR>/console/<port>.html`, private down its whole path, a page the harness reads as the browser does, forwarding with the token in its fragment, and with a record that agrees; - the page it opens, `/index.html` on `127.0.0.1`: 200, HTML and UTF-8, and the page whose bundle, routes and catalog the rest of the run reads; - the catalog: it refuses a caller with no token, and with the token answers in the envelope the chat rail adopts, with no available entry; - one live module script as the entry, and 38 modules, none bare, divergent, malformed or from outside the plane, every one served as JavaScript and every static import 200; `/views/intent-feed.js` refused as 10.2a's not-owed dynamic import; `/styles.css` served as `text/css`; no `<base>`, no import map, no classic or inline script, and nothing unmodeled; - all 18 derived routes and every document's `/source/` form below 500, none a redirect, and no branch-session column, so no thread read; - the stop: exit status 0 within the bound, no bundled PostgreSQL process left, the opener removed, and the token in neither output stream. Pass (a)'s route answers (`r76`): ``` /actions/{dtn-seed,edit,notebook,refresh,staging-seed} HTTP 404 /actions/workbench/{chat-turn,document-abstract,model-approval,model-intake} HTTP 404 /capabilities HTTP 200 /logout HTTP 404 /snapshot-index.json HTTP 404 /snapshot.json HTTP 200 /source/ HTTP 404 /source/<each of 8 documents> HTTP 200 /source/fixture%40main/<each of 8 documents> HTTP 200 /project-register.json HTTP 404 /workbench/model-catalog HTTP 200 /workbench/model-intake HTTP 200 /workbench/thread HTTP 400 ``` ### Before T084: two product gaps, both T084's, both removed by #77 The same harness against the integration without #77 (`e7187f06`: main + #69 `f66e5f82`, #72 `1b0c3a63`, #73 `71d24af6`, #71 `83213eb2`, #74 `9061b22a`, #65 `c0a97648`) held 198 and failed 4. The 4 were these two gaps, each hit in both repositories: ``` == r6-integ-keepgoing rc=1 held=198 AT-R1 HTTP half: FAIL [a.route /project-register.json]: GET /project-register.json answers no answer (RemoteDisconnected: ...) also FAIL [a.route /workbench/thread?repository=fixture&ref=main&tile_kind=cluster&tile_id=grouping-compost-corner&document=grouping-compost-corner.md]: ... no answer (RemoteDisconnected: ...) also FAIL [b.route /project-register.json]: ... no answer (RemoteDisconnected: ...) also FAIL [b.route /workbench/thread?repository=fixture&ref=main&tile_kind=cluster&tile_id=budget-roadmap&document=budget.md]: ... no answer (RemoteDisconnected: ...) 4 failed, 198 held ``` 1. **`GET /project-register.json` dropped the connection.** `src/opendox/serve_project.py:271` (at `main` `047bb4fa`) did `from openxdox.gate_console import DEFAULT_RECORDS_DIR` (and `:272`, `from openxdox.kickoff import …`), so it raised `ModuleNotFoundError: No module named 'openxdox'`. The page requests this route on every load (`app.js:1004` → `views/repo-selector.js:138-139`). It is batch L's named crash site (`5920216845`). **Removed by #77.** The handler now reads `column_seams.gate` and `column_seams.kickoff`. openDox's default discovers no register, so the route answers the existing structured `404 "no project register"`, and the picker hides. 2. **The chat rail's thread read dropped the connection.** `src/opendox/serve_workbench.py:560` at the pre-T084 integration (`:548` at `main`) did `from openxdox import doxbench_scope`, which is reached once the five query fields are present. The rail requests it when it opens on a document (`views/staging-workbench.js:2925`). It was **not** among batch L's three named crash sites; the holder relayed it to T084's writer. **Removed by #77.** The handler now uses openDox's own scope type. With no live session on that scope, it answers `403` with the no-live-session absence body. No `from openxdox` import is left in either file at `d05c266e`, now that #77 has landed on `main`, and the runs with #77 found **no new gap**. ### The assertions bite: twelve product mutants, four workflow mutants and 221 decision mutants, all killed Product mutants, each run through the full harness: m1, m2, m4 and m5 at `b4fc637f`, and m3 again at `b440d12b`. The only harness change since `b4fc637f` is `main()`'s handling of an error while preparing. m1 and m2 were merged from `main` when #69 was at `fedfa75d` and #72 at `20032d02`. m3 to m5 are working-tree edits of the integration as it was then, `8e4203d8` (before T084): | mutant | tree | killed at | |---|---|---| | m1, no T081 | integration without #74 (`25f1f6af`) | `[a.catalog offers no available entry]`: "no model is configured, yet the catalog offers ['omp-local']" | | m2, no T073 | integration without #72 (`f3957515`) | `[a.capabilities install.mode == local]`: "the served install block is None" | | m3, the server is never stopped | `bundle.BundledServer.stop()` returns at once, and no parent-death signal | `[a.stop leaves no bundled PostgreSQL process]`: "still running after the entry point stopped: pid 599656: …/pixeltable_pgserver/pginstall/bin/postgres -D …" (and the harness's cleanup then removed it) | | m4, a governance word in the fixture | `title: A slow leak at the rain barrel, ratified` | `[a.snapshot neutral (F5.3)]`: "openxFactory's vocabulary leaked into the neutral snapshot: ['ratified']" | | m5, a missing module | `src/opendox/web/views/lens-model.js` deleted | `[a.bundle.module /views/lens-model.js]`: "imported statically by /app.js, answers HTTP 404" | T104's delivery, each a working-tree edit of the integration WITH #84, `a945ef01`, run through the full harness at `d53a7378` (`r33`): | mutant | edit | killed at | |---|---|---| | m6, a readable opener | `console_access.PRIVATE_MODE = 0o644` | `[a.console opener is private]`: "… has mode 644, not 600" | | m7, the token in the query | `opened_url` joins with `?` instead of `#` | `[a.console opener forwards with the token in its fragment]`: "the opener's forward carries the token in its QUERY …" | | m8, the opener outlives the server | `remove_private_copy` returns at once | `[a.stop removes the console opener]`: "… is still there after the server stopped …" | | m9, the token back on `/capabilities` | `build_server` publishes it on every plane | `[a.capabilities carries no console token]` | | m10, the token printed | `generate-and-open` prints `token <token>` | `[a.console token never printed]` | | m11, an unguarded catalog | the catalog's console check removed | `[a.catalog refuses a caller without the console token]`: "… answers HTTP 200 …" | | m12, the token on `/capabilities` under another name | `build_server` adds `capabilities["session_hint"] = console_token`, at #84 `d4b99436` (`r51`) | `[a.capabilities carries the opener's token nowhere]`, and no token in the log | The harness's own decisions: **221 mutants**, each a one-line edit of `acceptance/at_r1_http.py` at this head, run against `tests/test_at_r1_http_harness.py`. **All 221 are killed**, each at a named case. By family: - the inert content of a page (`lv1`-`lv9`); - the state tree's rules and the `TMPDIR` refusal (`tr1`-`tr8`), and `console/` at 0700 exactly (`dm1`); - the redaction (`rd1`-`rd9`), the published token at any depth (`nv1`), the payloads never quoted (`fo1`, `im1`), and the tokens learned before step 5 (`pk1`); - the raw payload by name and by value (`rp1`-`rp6`), and the by-value check run (`rw1`); - the chat rail's thread read (`th1`-`th3`); - the opener read whole (`ol1`, `ol2`), the bodies and peer text never quoted (`cb1`, `cb2`, `en1`), and the `Content-Type` shown only as a plain type (`ct1`); - the plane's own origin (`og1`-`og5`); - JSON as the browser parses it (`js1`, `jc1`, `bj1`, `bj2`); - divergent references (`dv1`-`dv4`, `ui1`), and encoded dot segments only (`ed1`-`ed5`); JavaScript escapes (`le1`-`le4`), line comments and a hashbang (`le5`, `le6`), and escapes a module refuses (`ms1`-`ms8`); the page's links (`ht1`, `il1`, `il2`, `bs1`, `im2`); encoded targets (`bt1`); unterminated references (`ur1`); and availability as the rail reads it (`av1`); - only a live module script is an entry (`sr1`-`sr10`), and markup the harness does not model refused (`um1`-`um6`, `um6` an attribute's unterminated reference); - the page walked is the one the opener opens, asked of its own host with its query, as HTML and UTF-8, with its modules, sheets, routes and catalog at its origin (`cp1`-`cp12`), an IPv6 host bracketed (`og6`), and the page's links resolved against its base URL (`bd1`, `bd2`); - Unicode scalar values (`su1`-`su4`), and every printed line escaped (`pr1`-`pr5`); - JavaScript's whitespace, collapsed, and a method named `import` (`ws1`-`ws4`, `ff1`); a template's `${…}` read as code, starting an expression (`tp1`-`tp3`); a `/` read as division or a regular expression by what ends before it (`rg1`-`rg6`), and one right after a `}` refused (`am1`, `am2`); a member's name never a keyword (`km1`-`km4`), a spread's and a decimal literal's `.` no member access, and a name read whole, a private one with its `#` (`km5`-`km16`); every keyword read by where it stands, `of` only in a `for` head, `for await (` a condition, a spread before an expression (`kw1`-`kw15`), and `of` the separator only after a `for` head's binding or target (`os1`-`os10`); a regular expression after a division's `/` (`pu1`) and after a line break that ends a statement (`lb1`-`lb12`); and every character of a name read as the name's (`id1`); - a redirect refused (`rx1`, `rx2`), a repeated header joined (`hj1`), and `Content-Type` parsed as a browser parses it (`mt1`-`mt3`); - UTF-8 pages (`cs1`-`cs7`); - redaction through percent-encoding (`re1`, `re2`), and no reason quoting a value the catalog, the snapshot, the record or `/` sent (`cq1`, `cq2`, `sq1`, `gq1`, `rq1`, `rq2`, `bq1`). Earlier heads' decision mutants (64, from `d53a7378` to `64dc06f5`) were each killed at their own head; their decisions stand, and their cases still pass. Workflow mutants, against the three affected cases of `tests_runtime/test_deploy_shape.py`: | mutant | killed by | |---|---| | `acceptance` gains a `postgres` service | `test_the_acceptance_job_runs_the_harness_on_a_clean_machine` | | the harness drops `-c <lock>` | `test_every_install_of_this_package_reads_one_dependency_lock` | | `acceptance` chains `&& python -m pytest -q` | `test_the_acceptance_job_runs_the_harness_on_a_clean_machine` | | `acceptance` gains its own `pip install .` step | both cases | Restored: `3 passed`. Locally, `tests_runtime/test_deploy_shape.py` and `tests/test_triple_pin.py` give `115 passed`. ### How the harness is run, and where the bundled server's state lives The harness takes **no path and no port** (only `--keep` and `--keep-going`). It installs the checkout its own file is in, and its scratch and `OPENDOX_STATE_DIR` are fresh `tempfile` directories under `TMPDIR`. To measure an integration or a product mutant, this writer copied the harness, untracked, into that tree's `acceptance/` and ran it there. - **In CI**, they sit in the runner's temporary directory, `/tmp`, which is sticky and root's. - **The socket path is capped.** The bundled server's socket is `<OPENDOX_STATE_DIR>/postgres/run/.s.PGSQL.5432`, and Linux takes at most 107 bytes. A run whose socket could not fit is refused with exit 2, before anything is installed. - **The local runs above** used `TMPDIR=~/.local/state/t095-tmp`, a 0700 directory whose every ancestor is this user's or root's. It was empty after every run, and no bundled PostgreSQL started by these runs was left running. ## Review rounds - **SonarCloud** (advisory; the gate failed at `1c064bb3` on Security Rating D, and at `0717f72f` on Security Rating B, and passes at every other head since `bbdeb9ec`). At `0717f72f`, four `python:S5332` at `plane_origin`, which built an `http://` literal around the forward's host: fixed in `b7b9b843`, which builds the origin with `urlunsplit`. Fixed in `bbdeb9ec`: - `pythonsecurity:S8703`, `S8705`, `S8707`: the `--port`, `--scratch`, `--state-base` and `--checkout` options flowed into a socket, the server's argv and the filesystem. They are gone. - `python:S5443`: the `/tmp/.s.PGSQL.5432` probe is replaced by the distribution sockets. - `python:S3776`: the lexer is a class, and the long functions are split one per step. - `python:S9073`: the acceptance job's two assertions are split. - **Copilot**, 32 rounds. Every finding was real and was fixed with evidence, a case and a mutant; every thread is answered and resolved. Three overviews named a class with no finding: the line comments' (fixed), the identifiers' (fixed) and the URL resolution's (an accepted limit, see "Accepted limits"). - At `1c064bb3`, fixed in `32ef3e8c`: - `r4170450448`: a module refused as a dynamic import hid a later static import of the same path. - `r4170450491`: a malformed 200 catalog raised a harness error instead of a named failure. - At `bbdeb9ec`: `r4170537382`, the same static-after-dynamic case. Already covered by `32ef3e8c`, and answered there. - At `32ef3e8c`, fixed in `b4fc637f`: - `r4170537350`: a server on `/tmp`'s socket alone, with TCP off, passed the fixed socket list. - `r4170567257`: a non-list `documents` value passed as "non-empty". - A "previously missed" item: a grouping value with no tile holding an id and a member document passed, and silently dropped the rail's thread read. - At `b4fc637f`, "Needs a closer look" with no new finding. It noted that the PR needs integrated verification with the dependent product changes: that is the integration above, now including T084. Its "previously missed" item, fixed in `b440d12b`: an `OSError` in `prepare()` escaped `main()` with exit 1. This head returns 2; at `b4fc637f`, an `ENOSPC` from `tempfile.mkdtemp` escaped. - At `b440d12b`, "Needs a closer look" with no findings. Its "previously missed" item, fixed in `f0e0ffe1`: the module-graph verdict had no collected regression test, and on this branch the acceptance job stops before `derive_bundle`. The new `tests/test_at_r1_http_harness.py` above answers it. - At `f0e0ffe1`, "Needs a closer look" with no findings. Its "previously missed" item, fixed in `4dcb4221`: a 200 catalog of `{"models": []}` passed, though `adoptCatalog` adopts only `schema_version === 1` and `kind === "workbench-model-catalog"`, and shows any other catalog as unreadable. The envelope is now a named check, and the served bundle must name the kind. Seven refused envelopes are tested, and relaxing the int check fails the `true` case. - At `4dcb4221`, "Needs a closer look" with no findings. - At `1c0ff975`, fixed in `f29b4ddd`: `r4173473346`, the envelope check refused `"schema_version": 1.0` and `1e0`, which JavaScript's `=== 1` adopts. It now admits any JSON number equal to 1, and never `true` or `"1"`. - At `f29b4ddd` ("Changes recommended"), fixed in `de8b2274`: - `r4173769822`: a module served 200 as `text/plain` or `text/html` passed; - `r4173769844`: so did a stylesheet. Both now require their type. - At `d53a7378` ("Changes recommended"), fixed in `5636eb8d`: - `r4173842763`: a forward holding a backslash or user information passed, though a browser opens another host; - `r4173842794`: a refused destination's message quoted a URL that could hold the token; - `r4173842805` and `r4173842811`: an unparseable printed location or refresh URL raised a harness error instead of a named failure. - At `5636eb8d` ("Changes recommended"), fixed in `486e426e`: - `r4173894317`: a refresh whose delay a browser aborts (`invalid;url=`, `-1;url=`) still yielded a token; - `r4173894352`: a token that `takeDeliveredConsoleToken` discards (`short`) passed. - At `486e426e`, "Needs a closer look" with no findings. Its "previously missed" item, fixed in `142d1352`: with an agreeing record, a forward to `/missing.html` or `/snapshot.json` passed. The forward must now open `/` or `/index.html`. - At `142d1352` ("Changes recommended"), fixed in `27479495`: - `r4174355680`: a percent-encoded copy of the token in the query passed; - previously missed: a forward to `[::1]` passed, though the plane listens on 127.0.0.1 alone; - previously missed: an import from `https://…` was fetched as its local path and passed; - previously missed: any stop exit status passed. - At `27479495` ("Changes recommended"), fixed in `64dc06f5`: - `r4174411680`: a bare specifier (`import "child.js"`) passed when `/child.js` was served; - previously missed: a FIFO opener hung the harness under `--keep-going`. - At `33841d4a` ("Changes recommended"), fixed in `4809b3d2`: - `r4174621486`: a start or route failure quoted the tail of the entry point's output, which may hold the token; - `r4174621535`: `documents: [{}]` passed, while every document's source reads were skipped. - At `4809b3d2` ("Changes recommended"), fixed in `32fbb6dc`: - `r4174671390`: a refresh inside `<template>` or `<noscript>` counted as the opener's forward. Only a live refresh counts now, as a browser that runs scripts parses the page; - `r4174671426`: the privacy check stopped at `console/`. The opener's whole path is now judged by T104's own rules, and `prepare()` refuses an unsafe `TMPDIR`; - and, from the holder's brief, no line the harness prints quotes a token (`Verdict.redact`). - At `ec95f451` ("Changes recommended"), fixed in `4d0e6d10`: - `r4175016672`: a token nested in `/capabilities` was echoed by the install-mode reason. No step-5 reason quotes a payload now, a published token is kept secret at any depth, and the opener's tokens are learned before step 5; - `r4175016692`: a body nested past the JSON parser's depth was a harness ERROR (exit 2). It is a named failure now. - At `82869769` ("Changes recommended"), fixed in `2dcb98d3`: - `r4177924060`: an opener past 64 KiB was judged by its truncated prefix. It is read whole or refused; - `r4177924097`: the catalog's reasons quoted its body, where a `\u`-escaped token passed the literal redaction. No reason quotes the server's bytes now; - `r4177924129`: same-origin was `http://loopback`, so an absolute same-origin import was refused. It is the running server's origin now. - At `2dcb98d3` ("Changes recommended"), fixed in `4bdb41fb`: - `r4178069345`: `json.loads` admitted `NaN` and `Infinity`, which `response.json()` refuses; - `r4178069374`: a backslash URL resolved to a local file, where a browser goes elsewhere; - and, on the holder's word, one adversarial pass over every parser, closing the class: JSON decoding, JavaScript escapes, the page's links, `<base>` and import maps, user information, encoded dot segments, request-target encoding, unterminated character references, and availability as the rail reads it. - At `4bdb41fb` ("Changes recommended"), fixed in `ba216f84`: - `r4178395669`: every `<script src>` was a module root, whatever its type, and inside a `<template>` too. Only a live module script is an entry now; a classic or inline script is refused by name, and a page with no entry fails; - `r4178395690`: `\uD83D\uDE00` left two surrogates that no codec encodes. Every string from JavaScript or JSON is read in Unicode scalar values now; - and the pass that closed each class: markup the harness does not model, non-UTF-8 pages, escapes a module refuses, redirects, `Content-Type` parsing, and printed lines a server could forge. - At `ba216f84` ("Changes recommended"), fixed in `9229c659`: - `r4178913887`: U+FEFF after `import` hid the import, as Python's `\s` lacks it. The lexer reads JavaScript's own whitespace now, and every run as one space, so no run or comment hides an import either; - `r4178913911`: the catalog's reasons quoted its envelope values and available ids, where a percent-encoded token passed the redaction. No reason quotes a value the product sent now, and the redaction sees through percent-encoding; - `r4178913926`: an encoded dot inside a file name (`child%2Ejs`) was refused as a dot segment. Only a whole segment is one now. - At `9229c659`, "Needs a closer look" with no findings. Its overview, fixed in `3392f934`: a line comment ended only at LF, so one ended by CR, U+2028 or U+2029 hid the next line's import. A hashbang line is read as a comment too. - At `3392f934` ("Changes recommended"), fixed in `0717f72f`: - `r4179115282`: a template's `${…}` was skipped by counting braces, so an import or a route inside one was never seen. It is read as code now; - `r4179115311`: only `/` was checked and walked, though the opener opens `/index.html`. The page the forward opens is asked of the forward's own host, with its query, must be HTML and UTF-8, and is the page whose bundle, routes and catalog are read. - At `0717f72f` ("Changes recommended"), fixed in `b7b9b843`: - `r4179220628`: a `${` left the lexer after an expression, so a `/` opening a regular expression read as division, and the expression's `}` closed the interpolation early. An interpolation starts an expression now; - `r4179220641`: `html.unescape` read `©=2` in a page's `src` or `href`, which a browser keeps as text. Such a tag is refused by name, as the opener's refresh already was. - At `b7b9b843` ("Changes recommended"), fixed in `d50e8cef`: - `r4179348386`: after a postfix `++` or `--` (`n++ / 2`), a `/` was read as a regular expression, which swallowed the next import. The lexer now reads what ends before a `/`: a postfix operator, a call or a group divides, and a prefix operator or the `)` of an `if`, `while`, `for` or `with` condition opens a regular expression; - `r4179348414`: an accepted `<base href="/">` was ignored, so `src="?m=1"` on `/index.html` was asked as `/index.html?m=1`, not `/?m=1`. The page's scripts and stylesheets resolve against its base URL now. - At `d50e8cef` ("Changes recommended"), fixed in `04579fcd`: `r4180454790`, a `/` after a `}` that ends an expression (`{} / 2`, a function or class expression) divides, and was read as a regular expression. A lexer cannot tell such a `}` from a block's, so a module with a `/` right after a `}` is refused by name now (`[bundle.ambiguous-slash <path>]`). The real bundle has none (the lexer decides 213 `/` there, none after a `}`). `ff04e015` then merged `main` `32943cbf` (T104). - At `ff04e015` ("Changes recommended"), fixed in `1d581541`: `r4180554323`, a member named as a keyword (`obj.return / 2`, `obj.if(1) / 2`, `obj.of++ / 2`) was read as the keyword, so each `/` hid the import after it. A member's name is never a keyword now. The self-pass after it, in `ea4f7838`: the last `.` of a spread (`[...typeof /re/]`) and a decimal literal's own point (`1. in /re/`) are no member access, so the keyword after either is still one; and the last name is read whole, so `ñreturn` and `this.#return` are names. - At `ea4f7838`, "Needs a closer look" with no findings. Its overview named "a verified lexer error" that "can hide missing static dependencies". The pass that followed, in `bd50f405` (with a case in `234229d7` for a mutant that survived), found four, each hiding an import: `of` read as a keyword outside a `for` head (`const of = 4; of / 2`); no condition opened by `for await (`; a regular expression after a spread's `...` read as a division; and `default`, `extends`, and `break`, `continue` and `debugger` before a line break, after which an expression or a statement starts. - At `234229d7` ("Changes recommended"), fixed in `09d9d632`: `r4180899047`, `of` was a keyword anywhere in a `for` head, so in a classic loop's initializer, condition or update (`let of = 4; for (; of / 2; ) break;`) the `/` opened a regular expression that swallowed the import after it. `of` is a keyword only as the head's separator now, right after its binding or target. The self-pass after it, in `e4f81d48`, is a differential check of the lexer against node's own module parser, run locally (the static module requests of a `vm.SourceTextModule`). It covers 196 contexts, each a regular expression or a division before a static import, with five kinds of separator. Node accepts 978 of the snippets as modules. The lexer hid the import in 195 of them at `ff04e015`, and in 85 at `09d9d632`. It now hides it in 10, all one form: a declaration list's last binding with no initializer, then a line break and a regular expression (`let x, y` [line break] `/re/`), which would take a parser. It has no false failure, and the same 10 are refused by name throughout. The 85 were a regular expression after a division's own `/`, and after a line break that ends a statement by automatic semicolon insertion: before a `++` or `--`, after a labelled `break` or `continue`, a declaration's lone binding, or a static import's specifier. - At `e4f81d48`, "Needs a closer look" with no findings. Its overview named "URL-resolution and identifier-parsing errors". Under the holder's convergence ruling (see "Accepted limits"), the identifier class was a one-line fix with a test, made in `d698c6ea`: a ZWNJ, a ZWJ, a combining mark or another name character that Python's `\w` lacks split a name. The URL class is an accepted limit: it appears in contrived references only, and all 84 shipped specifiers resolve as the URL standard resolves them. - At `d698c6ea`, "Needs a closer look" with no findings. The overview says the harness's "custom browser parsing and database-process lifecycle checks warrant final integrated human verification", and names no form. It was the last push of the review rounds. - `d29e68ca` merges `main` `dede32b4` for the T095 refresh. The merge is clean (main and this branch share no file) and changes nothing of this PR's own. At `d29e68ca`: "Needs a closer look" with no findings and no thread. Its overview repeats `d698c6ea`'s: the browser modeling and the database-process lifecycle checks "warrant human sign-off despite reported green CI". It names no form, so there is nothing to classify. ## Plan changes this PR carries - **T007 batch N** (openxFactory `bdd0f586`, RULED `5963851934`): the raw `/capabilities` payload carries the console token neither by name, at any depth, nor by value, as quickstart § 3 asserts it; `console/` is 0700 exactly (`eb81b3d8`). The id CI's by-design failure names, `[a.capabilities carries no console token]`, is the by-name check. Product mutant m12 (the token under another key) is killed by the by-value check. - **The holder's T096 dry run, F4, after openDox-code#85** (the T102 follow-on, holder ruling F1 (i)): a standalone plane's rail sends no thread read, so the harness asks none with a query, and asserts the condition, no branch-session column (`4d0e6d10`). ## Not done here - No ruleset change: making `acceptance` required is the owner's. - No fix to any other writer's PR; gaps found are reported. - No README edit: the openDox root's README is T076's (openDox#17). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Plan 034 (
specs/034-opendox-standalone-operation/tasks.md, read at openxFactorymain2140f5a7), phase 3:tests/test_capability_honesty.py(new);tests/test_rejection_report.py(new).90ac7033), forserve.py's single-writer order T055 → T073 → T084, and T070 forcli.py's, T070 → T084.main, retargeted by the holder once T073 landed, so this diff is T084 alone: its own 28 files, the same list as93f77dc1..7536bfe2.620bee27atb333bf16, then T073, 13.4a — /capabilities gains an install block, read from the serving process's own settings (plan 034) #72's final pre-squash head93f77dc1at7536bfe2. That head carries T072, 13.1: the bundled PostgreSQL server, the local install's own child (plan 034) #69's final pre-squash headc04690f8and openDox-codemain5e7ab003(T072, 13.1: the bundled PostgreSQL server, the local install's own child (plan 034) #69's squash). Through stack A it also carries T085 (T085, the standalone doxBench defaults for T027's seams, and T058's two workbench rules move into opendox.validator (plan 034) #71), T088 (T088, the lens's two seed actions are offered only where a binding answers them (R1Q19 (a)) (plan 034) #65), T071 (T071, 13.2 and 13.3: load_settings refuses a non-PostgreSQL DSN and a collapsed DSN pair (plan 034) #60), T078 (T078, 16.1: the OpenAI-compatible dialect joins DIALECTS (plan 034) #61), T070 (T070, 13.4-13.6: OPENDOX_INSTALL_MODE and generate-and-open --local (plan 034) #67), T079 (T079, 16.2: a model field the provider receives (plan 034) #62) and T081 (T081, 16.4: "no model configured" is a state, shown before any turn (plan 034) #74).90ac7033,mainwas merged atd556c3fbwithgit merge-tree --merge-base 93f77dc1, recording both parents, so the squash's copy of T073 is not merged twice.90ac7033's tree equals93f77dc1merged onto8e377823over5e7ab003. Throughmainthis branch also takes T075 (T075, 10.2 and 10.2a — an installed entry point serves all 42 bundle files; intent-feed.js is not owed (plan 034) #73) and the broker-path hardening (Broker-path credential hardening (follows T080): a minted token keeps a built-in credential's rules (plan 034) #64).Ruled:
5817152735;5850003126;5920216845;5961364221, item 1;5961651355, "Tile's own documents editable (Recommended)";5960162524.Claimed on openxFactory#656 in
5960235138. T063 has landed (openxFactory#1218 →a883bbf6), and T073 has landed (openDox-code#72 →90ac7033). The holder posts READY and the landers merge.F4.1 whole
At this branch's base (T073's
bb05e3d7, before T084), the scan lists eleven reaches:src/opendox/consumer_reach.pywas present. Atb333bf16, and again atd556c3fbafter the merge ofmain:src/opendox/consumer_reach.pyis absent.tests/test_projection_seams.py::test_the_stand_ins_module_is_retiredholds the absence.What it does, commit by commit
Every broken rule, once (
5920216845item 3).cli._report_non_conformanceprints each broken rule id once, with its count and up to five of the places it is broken, then the validator's other lines.tests/test_rejection_report.py(new) covers a snapshot that breaks one rule several times and a second rule once.1(5 failed), never groups (5 failed), drops the places (3 failed).Capability honesty (
5920216845item 1).compute_capabilities(route_bindings=)setsgatetrue only where a contributed binding answersPOST /actions/gate/<verb>, andrefreshtrue only where one answersPOST /actions/refresh. A standalone server reads both false.047bb4fa: a standalone server with an identity answeredgateandrefreshtrue, and both routes answered404 unknown_action. The module's 20 cases then: 20 failed. After: 20 passed.The consumer columns' seams.
opendox.column_seamsdeclares four seams inprojection_seams' discipline:gate,scope,kickoffandregister.opendox.default_columnsholds openDox's own default for each (R1Q10 (a)). The four entry points register them where no host has, besideprojection_seams' defaults.GateRecordsNotRegistered, namingopendox.column_seams.gateand its registration call). openDox writes no governed shape it does not own. This is the holder's reading (B).The routing. Every reach above now reads its seam:
branch_session's gate, register and kickoff;cli'sgate_mod.The scope value types are openDox's own (
doxbench_scope_types).A tile's own documents are editable (
5961651355). This supersedes the holder's read-only reading. The neutral scope marks each section a tile projects as owned, as openXdox's authority marks its owned sections: a group's members, a selection's files, and a candidate's claiming groups' members. The set is ONE named function,default_columns.editable_paths. Nothing outside the tile is editable, an unresolved row is not, and neither is a created path.opendox --helpnames the installed command and openDox only. This is the holder's addition, found by T099's PyPI writer. T084 iscli.py's last phase-3 writer.usage: ideation-dashboardandcli.py's module docstring, which is openxFactory's pre-carve history ("validates it against the pinned openxFactory validator",python3 -m ideation_dashboard.cli).cli.PROG = "opendox", andPARSER_DESCRIPTIONandPARSER_EPILOGare neutral.create("ideation doc" becomes "document") andruntime(drops "(split-opendox § 3.5)").tests/test_installed_help.py(new) runs the INSTALLED console script, after checking that its entry point isopendox.cli:main. It assertsusage: opendoxand no openxFactory, xFactory, ideation-dashboard, split-opendox orscripts/wording.__doc__, 3 failed.The static bundle's content types are pinned. This is the holder's addition: T084 is
serve.py's last phase-3 writer. It comes from T075's finding on openDox-code#73 and realizes 10.2, "reachable in a browser from an openDox-only install".guess_typereadsextensions_mapfirst, and the platform'smimetypestable only after it. A host whose table maps.jstotext/plainserves every ES module as text, and a browser refuses to run it. Windows reads its table from the registry.serve.STATIC_CONTENT_TYPESpins every extension the bundle ships, measured from a built wheel: 41 files underopendox/web/, 39.js, one.htmland one.css. It also pins.mjs,.json,.svg,.png,.icoand.woff2..woff2, which that table lacks, takes its registered type (RFC 8081).consumer_reachis retired.LateGateRoutesandLateProjectionRoutesleaveDashboardHandler's bases. The gate and projection columns are a host's, composed in through the handler-contribution facet beside the bindings that name their methods (R1Q1 (a)). A host that contributes a binding without its column is refused at wiring, before a socket. openXdox contributes both columns that way at T086.The three crash sites, and a fourth
tests/test_capability_honesty.pyruns a standalonepython -m opendox.servechild with neither sibling importable. Each site gets a structured answer, neverRemoteDisconnected:model_capability_unavailableat step 1, now thatgatereads false;approval_refused, with the sentence naming the gate seam (see below);GET /project-register.json: today's 404 "no project register", from openDox's own kickoff default;repository=fixture&ref=main&tile_kind=cluster&tile_id=barrel-rain&document=<member>,views/staging-workbench.jsloadThread): the stated no-session absence. T095's AT-R1 harness found this site on openDox-code#75. A query-less GET stops at the 400 check first, which is why batch L's measurement missed it.Composed in-process hosts take the chat turn and the document abstract past their early checks, to the scope step that dropped the connection. The answer is a refusal in the released envelope.
Before (
a39e0201, the routing files at their pre-routing state), each of these fails withhttp.client.RemoteDisconnected: Remote end closed connection without response:Before, too, the standalone intake surface read
offered: true. It now readsoffered: false, with the reason (next section).Model intake and approval (Brett Heap, 2026-10-02;
5961364221item 1)The enrolment ends in a recorded approval, a governed gate-action record that only a HOST's gate writes. With no host's gate registered (
column_seams.gate_records_writable()):GET /workbench/model-intakeanswersoffered: falsewithcolumn_seams.GATE_RECORDS_REFUSALas its reason, even beside a hand-written broker block;The order is: no gate-record writer first, then no broker. A composed host that registers its gate is offered the flow, and its approval is recorded before the declarations document moves.
Mutants of the predicate killed: always true, 3 failed; always false, 2 failed.
Known, and out of this PR's scope:
declared_model_port_factoryserves only the FIRST approved binding (approved[0]).expires_atis recorded and not enforced.The chat scope (
5961651355)tests/test_neutral_turn_scope.py(new) runs over a composed host: the plain fixture, a loopback bind, an authenticated actor, the bindingopendox model-binding adddeclares, and the port the entry points declare over it. The host also has the released validators, as a plane with a readable contract has them.barrel-rain's own document passes the guard and reaches the model step. It answersmodel_unavailable, because it names a model the catalog lacks, so nothing is spawned or contacted.turn_scope_refused.POST /actions/gate/first-editanswersunknown_action, and the record a Save writes is refused namingopendox.column_seams.gate.python -m opendox.servechild with a binding configured): the same turn passes the guard and reaches the model step,model_unavailable, with openDox's own validators (T085, merged in at3387293e) and no stand-in. Until that merge the case asserted only a structured answer, as the holder accepted, and the merge commit narrowed it.tests/test_capability_honesty.py's standalone turn with a binding configured likewise now asserts the scope step'sturn_scope_refused.Before (the read-only default): 9 failed. The own-document turn answered 403
turn_scope_refused.Mutants killed:
The content types
tests/test_static_content_types.py(new):text/plain), every bundle file keeps its pinned type;Mutant killed: drop the pin, 2 failed. Under the hostile table,
index.htmland every.jswere servedtext/plain.The suite
The whole suite, locally (
LANG=C.UTF-8), at213344ad:tests/3026 passed, 11 skipped, andtests_runtime/632 passed, 166 skipped. The skip count is unchanged from T073's. The database-backed cases skip locally withoutOPENDOX_TEST_DATABASE_URL. No new case skips, soEXPECT_SKIPPEDdoes not move. As #67, #69 and #72 did, this PR does not editvalidate.yml.tests/test_consumer_reach.pykeeps its direction census. ItsCONVERTED_SITESguard is retired intotests/test_projection_seams.py's import-time rule, which now holds the column seams' proxies.opendox.consumer_reachleavesNEUTRAL_MODULES, the census "a removal from which has to be argued for". The argument: the module no longer exists.Fix round 1 (
b1db1965, Copilot's review at897029d8)projection_seams._Seamgains an optionalshapecheck, run after the name probe inregister()andregister_default().column_seams.gaterequiresGateRefusedto be an exception class, because openDox's verbs catch it.column_seams.registerrequiresCrossReferenceIndexAdapterto carry a callablediscover, becausebranch_sessioncalls it.tests/test_column_seams.py's docstring now describes the scope default as the ruling made it (r4170608087).Fix round 2 (
ff70ac1e, Copilot's review at3387293e)GateConsoleat construction, andcli._commission_clibuilt the console before itstry. A contributed gate verb that reached the default with no host's gate registered therefore ended in an uncaught traceback. The console is now built inside the refusal boundary.tests/test_column_seams.py::test_a_gate_verb_on_the_default_gate_is_refused_not_a_tracebackasserts exit status 1 andpropose refused: ..., namingopendox.column_seams.gate.register(. Before (3387293e): an uncaughtGateRecordsNotRegistered.Fix round 3 (
ebe0a35f, Copilot's review at1d6a4f19)document_edges[].documentnames a document by ID, and a selection'sfilesby PATH (the snapshot schema's$defs/idand$defs/path). openDox's default scope looked every reference up as a path.notes/soil-testand pathnotes/soil-test.mdleft every group and candidate member unresolved, with empty editable and context sets, and valid turns were refused.default_columns._document_indexmaps each listed document's id, then its path, to the document's path, as openXdox's authority does._sectionlooks each reference up there, carries the document's path (confined to the root as before) and keeps the reference as the row's id.tests/test_column_seams.py::test_a_group_edge_names_its_document_by_idcovers a snapshot whose ids differ from its paths. Before (b333bf16): failed, witheditable_paths()where('a.md', 'b.md')was expected. Mutant killed: references looked up as paths only, 1 failed.Fix round 4 (
1fb81cbd, Copilot's review atd556c3fb, the holder's ruling: ACCEPT both)files, which are PATHS. The contract does not forbid one document's path from spelling another document's id._document_indexnow returns_DocumentIndex(ids, paths).ids(id first, then path) serves group edges and a candidate's claiming groups.paths(path only) serves a selection's files.tests/test_column_seams.py::test_a_selection_file_is_a_path_where_it_spells_another_documents_id. Before (d556c3fb): failed, resolving toa.mdwheresel.mdwas expected. Mutants killed: the files looked up as ids (1 failed); the edges looked up as paths (2 failed).openxdox/doxbench_scope.py) still answers a selection's files from its one id-first map. Whether it follows is outside this PR.tests/test_installed_help.py::test_the_servers_help_states_loopback_as_the_default_bind. Before: failed. Mutant killed: the--hostclause dropped, 1 failed.Fix round 5 (
213344ad, Copilot's review at1fb81cbd, the holder's ruling: ACCEPT both)resolve_withinfollows a symlink to the canonical file, but a scope row keeps the spelling it was named by. Soalias.md -> ideation/dashboard/model-provider-bindings.yaml, or a directory link on the way to one, stayed owned and editable, bypassing M1.default_columns._settings_test(root)now compares the file a row REACHES with the files the settings documents reach._without_settingsmoves such an alias, under its own name, into thesettingssection that nothing owns: readable, never editable.tests/test_column_seams.py::test_an_in_root_alias_of_a_settings_document_is_never_editabletests a file alias and a directory alias. Before (1fb81cbd): 2 failed, e.g.('a.md', 'b.md', 'alias.md')where('a.md', 'b.md')was expected. Mutants killed: the target comparison dropped (2 failed); the alias compared by spelling (2 failed).tests/test_static_content_types.py).UNSHIPPEDclaimed the wheel leaves.gitkeepout, but since T075 it ships (web/**/.*). It is renamedTYPELESS_MARKERS: the shipped, empty, extensionless marker has no content type to pin.test_the_set_aside_markers_are_empty_and_extensionlessholds that reason. Mutant killed: the set widened toindex.html(2 failed).Adversarial review 2 (at
b1db1965), folded inEach item has a case that failed before its fix, and a mutant that fails it.
5a3b51ee).doxbench_binding.DEFAULT_BINDINGS_RELPATH) and the model declarations (doxbench_intake.DEFAULT_DECLARATIONS_RELPATH) live in the checkout, so a tile can name them. Under5961651355a group listing one made it editable, and a turn's proposal could then rewrite which provider a chat talks to.default_columns.SETTINGS_DOCUMENTSholds both.resolve_scopemoves them out of every owned section into a trailingsettingssection that nothing owns, so they stay readable.editable_pathsrefuses them whatever section carries them.1d6a4f19): 3 failed. Mutants killed: the function admits settings (1 failed); settings left in owned sections (2 failed).tile_kindis a malformed request, never a dropped connection (278855d4).ScopeKeyrefuses one with aValueError, which escaped three routes. Each now answers its fixed code:invalid_turn_request;invalid_abstract_request;invalid_turn_request, in the released envelope.5a3b51ee): 3 failed, eachRemoteDisconnected.generate-and-openremoves the run directory it minted (5a9cb0d8).--run-dirit mintedideation-dashboard-*and never removed it. Now it mintsopendox-*(cli.RUN_DIR_PREFIX) and removes it when the run ends: a served run stopped by an interrupt (and, in a local install, by SIGTERM), a--no-serverun, a refusal, a failure.--run-dirthe caller names is kept.tests/test_run_dir_lifetime.py(new). Before (278855d4): 2 failed. Mutant killed: the minted directory kept, 2 failed.opendox-*directory, because hosted mode installs no SIGTERM handler. A local run reads SIGTERM as an interrupt and cleans up.python -m opendox.serve --helpnames openDox only (6519660e). This is the same fix asopendox --help, for the server entry point's parser.serve.SERVE_PROG = "python -m opendox.serve", andSERVE_DESCRIPTIONis neutral.278855d4): 1 failed. Mutant killed: the prog reverted, 1 failed.Merges, done and ahead
3387293e. It conflicted only where measured:cli.pyandserve.py, at the fourregister_defaults()call sites and one import. Both sides are kept, T085'sdoxbench_defaultsfirst and T084'scolumn_seamsafter.last_assistant_turn_id, and_structured()reads the released failure envelope. Without it, both standalone turns failed with 400invalid_turn_request.3387293e, with the ruled one-line edit intests/test_lens_seed_actions.py:capabilities["actions"]["gate"] is Falsein both runs, with the identity asserted on the resolvedactorfield.b333bf16through stack A's merges of main and T073, 13.4a — /capabilities gains an install block, read from the serving process's own settings (plan 034) #72's merges of T072, 13.1: the bundled PostgreSQL server, the local install's own child (plan 034) #69.tests/test_chat_model_configuration.py'sopenxdoxstand-in to a scope authority registered atopendox.column_seams.scope(restoring the seam's state exactly), no longer applied once the landed file had moved. It is applied by hand and checked: before, 4 failed and 45 passed; after, 49 passed.93f77dc1, merged at7536bfe2, a plain merge with no conflict. It brings T072, 13.1: the bundled PostgreSQL server, the local install's own child (plan 034) #69's final rounds (17, 18a and 18b, throughbc85f214),main5e7ab003(T072, 13.1: the bundled PostgreSQL server, the local install's own child (plan 034) #69's squash, merged on T073, 13.4a — /capabilities gains an install block, read from the serving process's own settings (plan 034) #72 withmerge-tree --merge-base c04690f8), and T073, 13.4a — /capabilities gains an install block, read from the serving process's own settings (plan 034) #72's Copilot fix round (BundledServer.report()reads the process once).main90ac7033(T073, 13.4a — /capabilities gains an install block, read from the serving process's own settings (plan 034) #72's squash), merged atd556c3fbwithgit merge-tree --write-tree --merge-base 93f77dc1, recording both parents (7536bfe2,90ac7033), with no conflict. It brings T075 (T075, 10.2 and 10.2a — an installed entry point serves all 42 bundle files; intent-feed.js is not owed (plan 034) #73) and the broker-path hardening (Broker-path credential hardening (follows T080): a minted token keeps a built-in credential's rules (plan 034) #64), whosedoxbench_binding.pyanddoxbench_provider.pychanges merge clean with T084's. The F4.1 scan stays clean.Holder readings in this PR (Brett may overrule)
/project-register.jsonkeeps its 404.hosted_ref_refusedstaysserve.py's own.opendox --helpis the holder's addition, from T099's finding.🤖 Generated with Claude Code